@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,379 @@
1
+ /**
2
+ * @file slider-thumb.tsx
3
+ * MD3 Expressive Slider — Animated pill-shaped thumb (handle).
4
+ *
5
+ * Design decisions:
6
+ * 1. POINTER EVENTS (not Framer Motion drag): We use React pointer events
7
+ * + useMotionValue for real-time position tracking without re-render lag.
8
+ * Framer Motion's `drag` prop adds momentum/inertia that conflicts with MD3
9
+ * precise positioning; direct pointer handling gives us full control.
10
+ * 2. SQUEEZE ANIMATION: `whileTap` shrinks width from 4px → 2px via spring.
11
+ * 3. INVERTED Y-AXIS: Vertical slider maps bottom=min, top=max by inverting
12
+ * the pointer delta direction.
13
+ * 4. VALUE INDICATOR: AnimatePresence pill tooltip with teardrop origin point.
14
+ * 5. ACCESSIBILITY: role=slider, full ARIA attributes, keyboard nav.
15
+ * 6. prefers-reduced-motion: Disables all animations for accessibility.
16
+ *
17
+ * @see docs/m3/sliders/Slider.kt#SliderDefaults.Thumb
18
+ */
19
+
20
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
21
+ import * as React from "react";
22
+ import { cn } from "../../lib/utils";
23
+ import { getKeyboardDelta } from "./hooks/useSliderMath";
24
+ import {
25
+ SLIDER_INDICATOR_TRANSITION,
26
+ SLIDER_THUMB_SPRING,
27
+ SliderColors,
28
+ SliderTokens,
29
+ } from "./slider.tokens";
30
+ import type { SliderThumbProps, SliderTrackSize } from "./slider.types";
31
+
32
+ // ─── Value Indicator ─────────────────────────────────────────────────────────
33
+
34
+ interface ValueIndicatorProps {
35
+ value: number;
36
+ visible: boolean;
37
+ orientation: "horizontal" | "vertical";
38
+ formatValue?: (v: number) => string;
39
+ prefersReduced: boolean;
40
+ trackSize: SliderTrackSize;
41
+ }
42
+
43
+ /** Floating value label that appears above/beside thumb on hover/drag. */
44
+ const ValueIndicator = React.memo(function ValueIndicator({
45
+ value,
46
+ visible,
47
+ orientation,
48
+ formatValue,
49
+ prefersReduced,
50
+ trackSize,
51
+ }: ValueIndicatorProps) {
52
+ const label = formatValue ? formatValue(value) : String(Math.round(value));
53
+ const isHorizontal = orientation === "horizontal";
54
+
55
+ // Calculate dynamic offset so tooltip sits above the thumb regardless of trackSize
56
+ const thumbHeight = SliderTokens.thumbHeights[trackSize];
57
+ // Container center is 50%. The thumb top is `thumbHeight / 2` from center.
58
+ // Add an 8px visual gap to prevent the tooltip from overlapping the thumb.
59
+ const offsetFromCenter = thumbHeight / 2 + 8;
60
+
61
+ return (
62
+ <AnimatePresence>
63
+ {visible && (
64
+ <m.div
65
+ key="value-indicator"
66
+ role="tooltip"
67
+ aria-hidden="true"
68
+ initial={
69
+ prefersReduced
70
+ ? false
71
+ : {
72
+ scale: 0,
73
+ opacity: 0,
74
+ y: isHorizontal ? 6 : 0,
75
+ x: !isHorizontal ? -6 : 0,
76
+ }
77
+ }
78
+ animate={{ scale: 1, opacity: 1, y: 0, x: 0 }}
79
+ exit={
80
+ prefersReduced
81
+ ? {}
82
+ : {
83
+ scale: 0,
84
+ opacity: 0,
85
+ y: isHorizontal ? 6 : 0,
86
+ x: !isHorizontal ? -6 : 0,
87
+ }
88
+ }
89
+ transition={
90
+ prefersReduced ? { duration: 0 } : SLIDER_INDICATOR_TRANSITION
91
+ }
92
+ className={cn(
93
+ "absolute pointer-events-none select-none",
94
+ "rounded-full px-2.5 py-1 text-xs font-medium leading-none whitespace-nowrap",
95
+ // Position: above for horizontal, right for vertical
96
+ isHorizontal
97
+ ? "-translate-x-1/2 left-1/2"
98
+ : "translate-y-1/2 bottom-1/2",
99
+ )}
100
+ style={{
101
+ backgroundColor: SliderColors.valueIndicatorBg,
102
+ color: SliderColors.valueIndicatorText,
103
+ transformOrigin: isHorizontal ? "bottom center" : "center left",
104
+ ...(isHorizontal
105
+ ? { bottom: `calc(50% + ${offsetFromCenter}px)` }
106
+ : { left: `calc(50% + ${offsetFromCenter}px)` }),
107
+ }}
108
+ >
109
+ {label}
110
+ </m.div>
111
+ )}
112
+ </AnimatePresence>
113
+ );
114
+ });
115
+
116
+ // ─── SliderThumb ──────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * MD3 Expressive Slider Thumb (handle).
120
+ *
121
+ * - Pill shape: 4px wide × 44px tall by default.
122
+ * - Squeezes to 2px wide on press/drag (Framer Motion spring).
123
+ * - Floats above track via absolute positioning at `percent` along the track.
124
+ * - Handles pointer drag via React PointerEvents + useMotionValue.
125
+ * - Full keyboard navigation per WAI-ARIA Slider pattern.
126
+ * - Value indicator tooltip with AnimatePresence.
127
+ *
128
+ * @internal — consumed by `<Slider>` and `<RangeSlider>`
129
+ */
130
+ export const SliderThumb = React.memo(function SliderThumb({
131
+ value,
132
+ percent,
133
+ min,
134
+ max,
135
+ step,
136
+ disabled,
137
+ orientation,
138
+ showValueIndicator,
139
+ trackRef,
140
+ onValueChange,
141
+ onValueChangeEnd,
142
+ formatValue,
143
+ thumbId,
144
+ trackSize,
145
+ variant,
146
+ zIndex = 1,
147
+ "aria-label": ariaLabel,
148
+ "aria-labelledby": ariaLabelledBy,
149
+ }: SliderThumbProps) {
150
+ const isHorizontal = orientation === "horizontal";
151
+
152
+ // Map percent to [trackInset, 100% - trackInset]
153
+ // Cap trackInset to gap + half thumb to prevent ghost segments and allow dragging to edge.
154
+ const insetLimit = SliderTokens.thumbGap + SliderTokens.thumbWidthDefault / 2;
155
+ const trackInset = Math.min(
156
+ SliderTokens.trackSizes[trackSize] / 2,
157
+ insetLimit,
158
+ );
159
+ const posTarget = isHorizontal ? "left" : "bottom";
160
+ const motionStyle = {
161
+ [posTarget]: `calc(${trackInset}px + ${percent} * (100% - ${trackInset * 2}px))`,
162
+ };
163
+
164
+ const prefersReduced = useReducedMotion() ?? false;
165
+
166
+ const [isDragging, setIsDragging] = React.useState(false);
167
+ const [isHovered, setIsHovered] = React.useState(false);
168
+ const [isFocused, setIsFocused] = React.useState(false);
169
+
170
+ // Track pointer capture ID
171
+ const pointerIdRef = React.useRef<number | null>(null);
172
+ const thumbRef = React.useRef<HTMLDivElement | null>(null);
173
+
174
+ const showIndicator =
175
+ showValueIndicator && (isDragging || isHovered || isFocused);
176
+
177
+ // ── Position calculation ────────────────────────────────────────────────
178
+ // Position the thumb center at `percent` along the track.
179
+ // Offset by half-thumb to center it on the track line.
180
+
181
+ const positionStyle: React.CSSProperties = isHorizontal
182
+ ? {
183
+ position: "absolute",
184
+ ...motionStyle,
185
+ top: "50%",
186
+ transform: "translate(-50%, -50%)",
187
+ zIndex,
188
+ }
189
+ : {
190
+ position: "absolute",
191
+ ...motionStyle,
192
+ left: "50%",
193
+ transform: "translate(-50%, 50%)",
194
+ zIndex,
195
+ };
196
+
197
+ // ── Pointer drag handlers ───────────────────────────────────────────────
198
+
199
+ const getDeltaFromPointer = React.useCallback(
200
+ (e: PointerEvent): number => {
201
+ const trackEl = trackRef.current;
202
+ if (!trackEl) return percent;
203
+
204
+ const rect = trackEl.getBoundingClientRect();
205
+ const space = isHorizontal ? rect.width : rect.height;
206
+ const insetLimit =
207
+ SliderTokens.thumbGap + SliderTokens.thumbWidthDefault / 2;
208
+ const trackInset = Math.min(
209
+ SliderTokens.trackSizes[trackSize] / 2,
210
+ insetLimit,
211
+ );
212
+ const safeSpace = space - trackInset * 2;
213
+ if (safeSpace <= 0) return percent;
214
+
215
+ let rawPercent: number;
216
+ if (isHorizontal) {
217
+ rawPercent = (e.clientX - rect.left - trackInset) / safeSpace;
218
+ } else {
219
+ // Inverted Y: bottom = min (0%), top = max (100%)
220
+ rawPercent = (rect.bottom - e.clientY - trackInset) / safeSpace;
221
+ }
222
+
223
+ const clamped = Math.max(0, Math.min(1, rawPercent));
224
+ const range = max - min;
225
+ const rawValue = min + clamped * range;
226
+ const snapped =
227
+ step > 0 ? Math.round((rawValue - min) / step) * step + min : rawValue;
228
+ return Math.max(min, Math.min(max, snapped));
229
+ },
230
+ [isHorizontal, max, min, percent, step, trackRef, trackSize],
231
+ );
232
+
233
+ const handlePointerDown = React.useCallback(
234
+ (e: React.PointerEvent<HTMLDivElement>) => {
235
+ if (disabled) return;
236
+ e.preventDefault();
237
+ e.stopPropagation();
238
+ (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
239
+ pointerIdRef.current = e.pointerId;
240
+ setIsDragging(true);
241
+ },
242
+ [disabled],
243
+ );
244
+
245
+ const handlePointerMove = React.useCallback(
246
+ (e: React.PointerEvent<HTMLDivElement>) => {
247
+ if (!isDragging || e.pointerId !== pointerIdRef.current) return;
248
+ const newValue = getDeltaFromPointer(e.nativeEvent);
249
+ onValueChange(newValue);
250
+ },
251
+ [isDragging, getDeltaFromPointer, onValueChange],
252
+ );
253
+
254
+ const handlePointerUp = React.useCallback(
255
+ (e: React.PointerEvent<HTMLDivElement>) => {
256
+ if (e.pointerId !== pointerIdRef.current) return;
257
+ (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
258
+ pointerIdRef.current = null;
259
+ setIsDragging(false);
260
+ onValueChangeEnd?.(value);
261
+ },
262
+ [onValueChangeEnd, value],
263
+ );
264
+
265
+ // ── Keyboard navigation ──────────────────────────────────────────────────
266
+
267
+ const handleKeyDown = React.useCallback(
268
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
269
+ if (disabled) return;
270
+
271
+ if (e.key === "Home") {
272
+ e.preventDefault();
273
+ onValueChange(min);
274
+ onValueChangeEnd?.(min);
275
+ return;
276
+ }
277
+ if (e.key === "End") {
278
+ e.preventDefault();
279
+ onValueChange(max);
280
+ onValueChangeEnd?.(max);
281
+ return;
282
+ }
283
+
284
+ const delta = getKeyboardDelta(e.key, step, min, max);
285
+ if (delta === null) return;
286
+ e.preventDefault();
287
+
288
+ const newValue = Math.max(min, Math.min(max, value + delta));
289
+ onValueChange(newValue);
290
+ onValueChangeEnd?.(newValue);
291
+ },
292
+ [disabled, max, min, onValueChange, onValueChangeEnd, step, value],
293
+ );
294
+
295
+ // ── Render ───────────────────────────────────────────────────────────────
296
+ const thumbColor = disabled
297
+ ? SliderColors.disabledThumb
298
+ : `var(--md-sys-color-${variant})`;
299
+
300
+ const squeezeSize = isDragging
301
+ ? SliderTokens.thumbWidthPressed
302
+ : SliderTokens.thumbWidthDefault;
303
+
304
+ return (
305
+ <div
306
+ style={{
307
+ ...positionStyle,
308
+ width: SliderTokens.thumbTouchTarget,
309
+ height: SliderTokens.thumbTouchTarget,
310
+ display: "flex",
311
+ alignItems: "center",
312
+ justifyContent: "center",
313
+ touchAction: "none",
314
+ }}
315
+ className={cn(
316
+ "cursor-grab",
317
+ isDragging && "cursor-grabbing",
318
+ disabled && "cursor-not-allowed",
319
+ )}
320
+ onPointerDown={handlePointerDown}
321
+ onPointerMove={handlePointerMove}
322
+ onPointerUp={handlePointerUp}
323
+ onPointerCancel={handlePointerUp}
324
+ >
325
+ <ValueIndicator
326
+ value={value}
327
+ visible={showIndicator}
328
+ orientation={orientation}
329
+ formatValue={formatValue}
330
+ prefersReduced={prefersReduced}
331
+ trackSize={trackSize}
332
+ />
333
+
334
+ <m.div
335
+ ref={thumbRef}
336
+ id={thumbId}
337
+ role="slider"
338
+ aria-valuemin={min}
339
+ aria-valuemax={max}
340
+ aria-valuenow={value}
341
+ aria-orientation={orientation}
342
+ aria-disabled={disabled || undefined}
343
+ aria-label={ariaLabel}
344
+ aria-labelledby={ariaLabelledBy}
345
+ tabIndex={disabled ? -1 : 0}
346
+ onKeyDown={handleKeyDown}
347
+ onFocus={() => setIsFocused(true)}
348
+ onBlur={() => setIsFocused(false)}
349
+ onMouseEnter={() => !disabled && setIsHovered(true)}
350
+ onMouseLeave={() => setIsHovered(false)}
351
+ animate={{
352
+ ...(isHorizontal ? { width: squeezeSize } : { height: squeezeSize }),
353
+ backgroundColor: thumbColor,
354
+ opacity: disabled ? 0.38 : 1,
355
+ }}
356
+ transition={prefersReduced ? { duration: 0 } : SLIDER_THUMB_SPRING}
357
+ className={cn(
358
+ "relative shrink-0 rounded-full select-none outline-none",
359
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
360
+ "focus-visible:outline-(--md-sys-color-secondary)",
361
+ )}
362
+ style={{
363
+ ...(isHorizontal
364
+ ? {
365
+ height: SliderTokens.thumbHeights[trackSize],
366
+ width: SliderTokens.thumbWidthDefault,
367
+ willChange: "width",
368
+ }
369
+ : {
370
+ width: SliderTokens.thumbHeights[trackSize],
371
+ height: SliderTokens.thumbWidthDefault,
372
+ willChange: "height",
373
+ }),
374
+ backgroundColor: thumbColor,
375
+ }}
376
+ />
377
+ </div>
378
+ );
379
+ });