@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,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
+ });