@zentauri-ui/zentauri-components 1.3.1 → 1.4.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 (307) hide show
  1. package/README.md +78 -0
  2. package/cli/cli.integration.test.ts +51 -0
  3. package/cli/index.mjs +664 -0
  4. package/cli/registry.json +36 -0
  5. package/cli/rewrite-imports.mjs +57 -0
  6. package/cli/rewrite-imports.test.ts +71 -0
  7. package/dist/ui/slider/slider.d.ts +18 -0
  8. package/dist/ui/slider/slider.d.ts.map +1 -1
  9. package/dist/ui/slider.js +21 -25
  10. package/dist/ui/slider.js.map +1 -1
  11. package/dist/ui/slider.mjs +21 -25
  12. package/dist/ui/slider.mjs.map +1 -1
  13. package/package.json +8 -2
  14. package/src/hooks/index.ts +48 -0
  15. package/src/hooks/useBodyScrollLock/index.ts +1 -0
  16. package/src/hooks/useBodyScrollLock/useBodyScrollLock.test.ts +51 -0
  17. package/src/hooks/useBodyScrollLock/useBodyScrollLock.ts +48 -0
  18. package/src/hooks/useClickOutside/index.ts +5 -0
  19. package/src/hooks/useClickOutside/useClickOutside.test.tsx +60 -0
  20. package/src/hooks/useClickOutside/useClickOutside.ts +52 -0
  21. package/src/hooks/useClipboard/index.ts +1 -0
  22. package/src/hooks/useClipboard/useClipboard.test.ts +101 -0
  23. package/src/hooks/useClipboard/useClipboard.ts +69 -0
  24. package/src/hooks/useControllableState/index.ts +4 -0
  25. package/src/hooks/useControllableState/useControllableState.test.ts +59 -0
  26. package/src/hooks/useControllableState/useControllableState.ts +49 -0
  27. package/src/hooks/useDebouncedValue/index.ts +1 -0
  28. package/src/hooks/useDebouncedValue/useDebouncedValue.test.ts +74 -0
  29. package/src/hooks/useDebouncedValue/useDebouncedValue.ts +29 -0
  30. package/src/hooks/useDisclosure/index.ts +5 -0
  31. package/src/hooks/useDisclosure/useDisclosure.test.ts +64 -0
  32. package/src/hooks/useDisclosure/useDisclosure.ts +62 -0
  33. package/src/hooks/useDocumentTitle/index.ts +4 -0
  34. package/src/hooks/useDocumentTitle/useDocumentTitle.test.ts +40 -0
  35. package/src/hooks/useDocumentTitle/useDocumentTitle.ts +58 -0
  36. package/src/hooks/useFocusManagement/index.ts +1 -0
  37. package/src/hooks/useFocusManagement/useFocusManagement.test.tsx +45 -0
  38. package/src/hooks/useFocusManagement/useFocusManagement.ts +77 -0
  39. package/src/hooks/useHover/index.ts +1 -0
  40. package/src/hooks/useHover/useHover.test.ts +45 -0
  41. package/src/hooks/useHover/useHover.ts +45 -0
  42. package/src/hooks/useInView/index.ts +1 -0
  43. package/src/hooks/useInView/useInView.test.ts +43 -0
  44. package/src/hooks/useInView/useInView.ts +28 -0
  45. package/src/hooks/useIntersectionObserver/index.ts +4 -0
  46. package/src/hooks/useIntersectionObserver/useIntersectionObserver.test.ts +75 -0
  47. package/src/hooks/useIntersectionObserver/useIntersectionObserver.ts +54 -0
  48. package/src/hooks/useIsMounted/index.ts +1 -0
  49. package/src/hooks/useIsMounted/useIsMounted.test.ts +25 -0
  50. package/src/hooks/useIsMounted/useIsMounted.ts +22 -0
  51. package/src/hooks/useIsomorphicLayoutEffect/index.ts +1 -0
  52. package/src/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.test.ts +19 -0
  53. package/src/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.ts +12 -0
  54. package/src/hooks/useLocalStorage/index.ts +4 -0
  55. package/src/hooks/useLocalStorage/useLocalStorage.test.ts +99 -0
  56. package/src/hooks/useLocalStorage/useLocalStorage.ts +109 -0
  57. package/src/hooks/useMediaQuery/index.ts +1 -0
  58. package/src/hooks/useMediaQuery/useMediaQuery.test.ts +63 -0
  59. package/src/hooks/useMediaQuery/useMediaQuery.ts +37 -0
  60. package/src/hooks/useNetworkStatus/index.ts +1 -0
  61. package/src/hooks/useNetworkStatus/useNetworkStatus.test.ts +53 -0
  62. package/src/hooks/useNetworkStatus/useNetworkStatus.ts +33 -0
  63. package/src/hooks/usePageVisibility/index.ts +1 -0
  64. package/src/hooks/usePageVisibility/usePageVisibility.test.ts +21 -0
  65. package/src/hooks/usePageVisibility/usePageVisibility.ts +31 -0
  66. package/src/hooks/usePagination/index.ts +6 -0
  67. package/src/hooks/usePagination/usePagination.test.ts +139 -0
  68. package/src/hooks/usePagination/usePagination.ts +153 -0
  69. package/src/hooks/usePrefersColorScheme/index.ts +4 -0
  70. package/src/hooks/usePrefersColorScheme/usePrefersColorScheme.test.ts +53 -0
  71. package/src/hooks/usePrefersColorScheme/usePrefersColorScheme.ts +21 -0
  72. package/src/hooks/usePrefersReducedMotion/index.ts +1 -0
  73. package/src/hooks/usePrefersReducedMotion/usePrefersReducedMotion.test.ts +27 -0
  74. package/src/hooks/usePrefersReducedMotion/usePrefersReducedMotion.ts +14 -0
  75. package/src/hooks/useResizeObserver/index.ts +4 -0
  76. package/src/hooks/useResizeObserver/useResizeObserver.test.ts +68 -0
  77. package/src/hooks/useResizeObserver/useResizeObserver.ts +58 -0
  78. package/src/hooks/useSessionStorage/index.ts +4 -0
  79. package/src/hooks/useSessionStorage/useSessionStorage.test.ts +54 -0
  80. package/src/hooks/useSessionStorage/useSessionStorage.ts +84 -0
  81. package/src/hooks/useThrottledCallback/index.ts +1 -0
  82. package/src/hooks/useThrottledCallback/useThrottledCallback.test.ts +75 -0
  83. package/src/hooks/useThrottledCallback/useThrottledCallback.ts +36 -0
  84. package/src/hooks/useToggle/index.ts +1 -0
  85. package/src/hooks/useToggle/useToggle.test.ts +40 -0
  86. package/src/hooks/useToggle/useToggle.ts +22 -0
  87. package/src/hooks/useWindowSize/index.ts +1 -0
  88. package/src/hooks/useWindowSize/useWindowSize.test.ts +23 -0
  89. package/src/hooks/useWindowSize/useWindowSize.ts +39 -0
  90. package/src/lib/utils.ts +25 -0
  91. package/src/ui/accordion/accordion-base.tsx +223 -0
  92. package/src/ui/accordion/accordion.test.tsx +146 -0
  93. package/src/ui/accordion/accordion.tsx +11 -0
  94. package/src/ui/accordion/animated/accordion-content-animated.tsx +46 -0
  95. package/src/ui/accordion/animated/accordion-root-animated.tsx +10 -0
  96. package/src/ui/accordion/animated/animations.ts +16 -0
  97. package/src/ui/accordion/animated/index.ts +7 -0
  98. package/src/ui/accordion/animated/types.ts +7 -0
  99. package/src/ui/accordion/index.ts +23 -0
  100. package/src/ui/accordion/types.ts +48 -0
  101. package/src/ui/accordion/variants.ts +115 -0
  102. package/src/ui/alert/alert-base.tsx +157 -0
  103. package/src/ui/alert/alert.test.tsx +150 -0
  104. package/src/ui/alert/alert.tsx +9 -0
  105. package/src/ui/alert/animated/alert-animated.tsx +20 -0
  106. package/src/ui/alert/animated/animations.ts +20 -0
  107. package/src/ui/alert/animated/index.ts +3 -0
  108. package/src/ui/alert/animated/types.ts +16 -0
  109. package/src/ui/alert/index.ts +22 -0
  110. package/src/ui/alert/types.ts +28 -0
  111. package/src/ui/alert/variants.ts +74 -0
  112. package/src/ui/avatar/animated/animations.ts +11 -0
  113. package/src/ui/avatar/animated/avatar-animated.tsx +25 -0
  114. package/src/ui/avatar/animated/index.ts +6 -0
  115. package/src/ui/avatar/animated/types.ts +16 -0
  116. package/src/ui/avatar/avatar-base.tsx +184 -0
  117. package/src/ui/avatar/avatar.test.tsx +51 -0
  118. package/src/ui/avatar/avatar.tsx +11 -0
  119. package/src/ui/avatar/index.ts +16 -0
  120. package/src/ui/avatar/types.ts +36 -0
  121. package/src/ui/avatar/variants.ts +52 -0
  122. package/src/ui/badge/animated/animations.ts +20 -0
  123. package/src/ui/badge/animated/badge-animated.tsx +28 -0
  124. package/src/ui/badge/animated/index.ts +5 -0
  125. package/src/ui/badge/animated/types.ts +18 -0
  126. package/src/ui/badge/badge-base.tsx +53 -0
  127. package/src/ui/badge/badge.test.tsx +48 -0
  128. package/src/ui/badge/badge.tsx +9 -0
  129. package/src/ui/badge/index.ts +5 -0
  130. package/src/ui/badge/types.ts +25 -0
  131. package/src/ui/badge/variants.ts +85 -0
  132. package/src/ui/breadcrumb/breadcrumb.test.tsx +62 -0
  133. package/src/ui/breadcrumb/breadcrumb.tsx +135 -0
  134. package/src/ui/breadcrumb/index.ts +28 -0
  135. package/src/ui/breadcrumb/types.ts +29 -0
  136. package/src/ui/breadcrumb/variants.ts +53 -0
  137. package/src/ui/buttons/animated/animations.ts +34 -0
  138. package/src/ui/buttons/animated/button-animated.tsx +70 -0
  139. package/src/ui/buttons/animated/index.ts +5 -0
  140. package/src/ui/buttons/animated/types.ts +29 -0
  141. package/src/ui/buttons/button-base.tsx +59 -0
  142. package/src/ui/buttons/button.test.tsx +480 -0
  143. package/src/ui/buttons/button.tsx +9 -0
  144. package/src/ui/buttons/index.ts +5 -0
  145. package/src/ui/buttons/types.ts +14 -0
  146. package/src/ui/buttons/variants.ts +77 -0
  147. package/src/ui/card/animated/animations.ts +32 -0
  148. package/src/ui/card/animated/card-animated.tsx +28 -0
  149. package/src/ui/card/animated/index.ts +12 -0
  150. package/src/ui/card/animated/types.ts +8 -0
  151. package/src/ui/card/card-base.tsx +146 -0
  152. package/src/ui/card/card.test.tsx +79 -0
  153. package/src/ui/card/card.tsx +11 -0
  154. package/src/ui/card/index.ts +21 -0
  155. package/src/ui/card/types.ts +42 -0
  156. package/src/ui/card/variants.ts +122 -0
  157. package/src/ui/divider/animated/animations.ts +27 -0
  158. package/src/ui/divider/animated/divider-animated.tsx +24 -0
  159. package/src/ui/divider/animated/index.ts +4 -0
  160. package/src/ui/divider/animated/types.ts +18 -0
  161. package/src/ui/divider/divider-base.tsx +80 -0
  162. package/src/ui/divider/divider.tsx +9 -0
  163. package/src/ui/divider/index.ts +14 -0
  164. package/src/ui/divider/types.ts +18 -0
  165. package/src/ui/divider/variants.ts +98 -0
  166. package/src/ui/drawer/animated/animations.ts +39 -0
  167. package/src/ui/drawer/animated/drawer-content-animated.tsx +101 -0
  168. package/src/ui/drawer/animated/index.ts +14 -0
  169. package/src/ui/drawer/animated/types.ts +18 -0
  170. package/src/ui/drawer/drawer-base.tsx +259 -0
  171. package/src/ui/drawer/drawer.test.tsx +132 -0
  172. package/src/ui/drawer/drawer.tsx +11 -0
  173. package/src/ui/drawer/index.ts +21 -0
  174. package/src/ui/drawer/types.ts +39 -0
  175. package/src/ui/drawer/variants.ts +122 -0
  176. package/src/ui/dropdown/dropdown.test.tsx +114 -0
  177. package/src/ui/dropdown/dropdown.tsx +179 -0
  178. package/src/ui/dropdown/index.ts +15 -0
  179. package/src/ui/dropdown/types.ts +68 -0
  180. package/src/ui/dropdown/variants.ts +138 -0
  181. package/src/ui/empty-state/animated/animations.ts +19 -0
  182. package/src/ui/empty-state/animated/empty-state-animated.tsx +23 -0
  183. package/src/ui/empty-state/animated/index.ts +7 -0
  184. package/src/ui/empty-state/animated/types.ts +26 -0
  185. package/src/ui/empty-state/empty-state-base.tsx +114 -0
  186. package/src/ui/empty-state/empty-state.tsx +9 -0
  187. package/src/ui/empty-state/index.ts +10 -0
  188. package/src/ui/empty-state/types.ts +19 -0
  189. package/src/ui/empty-state/variants.ts +51 -0
  190. package/src/ui/file-upload/file-upload.test.tsx +36 -0
  191. package/src/ui/file-upload/file-upload.tsx +119 -0
  192. package/src/ui/file-upload/index.ts +5 -0
  193. package/src/ui/file-upload/types.ts +21 -0
  194. package/src/ui/file-upload/variants.ts +29 -0
  195. package/src/ui/inputs/animated/animations.ts +36 -0
  196. package/src/ui/inputs/animated/index.ts +5 -0
  197. package/src/ui/inputs/animated/input-animated.tsx +124 -0
  198. package/src/ui/inputs/animated/types.ts +40 -0
  199. package/src/ui/inputs/index.ts +5 -0
  200. package/src/ui/inputs/input-base.tsx +114 -0
  201. package/src/ui/inputs/input.test.tsx +414 -0
  202. package/src/ui/inputs/input.tsx +8 -0
  203. package/src/ui/inputs/types.ts +18 -0
  204. package/src/ui/inputs/variants.ts +316 -0
  205. package/src/ui/modal/animated/animations.ts +29 -0
  206. package/src/ui/modal/animated/index.ts +5 -0
  207. package/src/ui/modal/animated/modal-content-animated.tsx +96 -0
  208. package/src/ui/modal/animated/types.ts +23 -0
  209. package/src/ui/modal/index.ts +21 -0
  210. package/src/ui/modal/modal-base.tsx +279 -0
  211. package/src/ui/modal/modal.test.tsx +129 -0
  212. package/src/ui/modal/modal.tsx +8 -0
  213. package/src/ui/modal/types.ts +31 -0
  214. package/src/ui/modal/variants.ts +109 -0
  215. package/src/ui/pagination/index.ts +13 -0
  216. package/src/ui/pagination/pagination.test.tsx +165 -0
  217. package/src/ui/pagination/pagination.tsx +237 -0
  218. package/src/ui/pagination/types.ts +66 -0
  219. package/src/ui/pagination/variants.ts +97 -0
  220. package/src/ui/progress/animated/animations.ts +9 -0
  221. package/src/ui/progress/animated/index.ts +17 -0
  222. package/src/ui/progress/animated/progress-animated.tsx +133 -0
  223. package/src/ui/progress/animated/types.ts +35 -0
  224. package/src/ui/progress/index.ts +10 -0
  225. package/src/ui/progress/progress-base.tsx +151 -0
  226. package/src/ui/progress/progress.test.tsx +84 -0
  227. package/src/ui/progress/progress.tsx +12 -0
  228. package/src/ui/progress/types.ts +33 -0
  229. package/src/ui/progress/variants.ts +105 -0
  230. package/src/ui/select/index.ts +25 -0
  231. package/src/ui/select/select.test.tsx +128 -0
  232. package/src/ui/select/select.tsx +221 -0
  233. package/src/ui/select/types.ts +77 -0
  234. package/src/ui/select/variants.ts +163 -0
  235. package/src/ui/skeleton/animated/animations.ts +15 -0
  236. package/src/ui/skeleton/animated/index.ts +20 -0
  237. package/src/ui/skeleton/animated/skeleton-animated.tsx +119 -0
  238. package/src/ui/skeleton/animated/types.ts +49 -0
  239. package/src/ui/skeleton/index.ts +24 -0
  240. package/src/ui/skeleton/skeleton-base.tsx +288 -0
  241. package/src/ui/skeleton/skeleton.tsx +8 -0
  242. package/src/ui/skeleton/types.ts +31 -0
  243. package/src/ui/skeleton/variants.ts +254 -0
  244. package/src/ui/slider/index.ts +22 -0
  245. package/src/ui/slider/slider.test.tsx +94 -0
  246. package/src/ui/slider/slider.tsx +728 -0
  247. package/src/ui/slider/types.ts +66 -0
  248. package/src/ui/slider/variants.ts +81 -0
  249. package/src/ui/spinner/animated/index.ts +5 -0
  250. package/src/ui/spinner/animated/spinner.test.tsx +41 -0
  251. package/src/ui/spinner/animated/spinner.tsx +143 -0
  252. package/src/ui/spinner/animated/types.ts +11 -0
  253. package/src/ui/spinner/animated/variants.ts +50 -0
  254. package/src/ui/stepper/index.ts +22 -0
  255. package/src/ui/stepper/stepper.test.tsx +183 -0
  256. package/src/ui/stepper/stepper.tsx +172 -0
  257. package/src/ui/stepper/types.ts +32 -0
  258. package/src/ui/stepper/variants.ts +69 -0
  259. package/src/ui/table/animated/animations.ts +9 -0
  260. package/src/ui/table/animated/index.ts +15 -0
  261. package/src/ui/table/animated/table-animated.tsx +15 -0
  262. package/src/ui/table/animated/types.ts +16 -0
  263. package/src/ui/table/index.ts +22 -0
  264. package/src/ui/table/table-base.tsx +197 -0
  265. package/src/ui/table/table.tsx +13 -0
  266. package/src/ui/table/types.ts +47 -0
  267. package/src/ui/table/variants.ts +105 -0
  268. package/src/ui/tabs/animated/animations.ts +48 -0
  269. package/src/ui/tabs/animated/index.ts +8 -0
  270. package/src/ui/tabs/animated/tabs-content-animated.tsx +46 -0
  271. package/src/ui/tabs/animated/types.ts +24 -0
  272. package/src/ui/tabs/index.ts +10 -0
  273. package/src/ui/tabs/tabs-base.tsx +185 -0
  274. package/src/ui/tabs/tabs.test.tsx +53 -0
  275. package/src/ui/tabs/tabs.tsx +2 -0
  276. package/src/ui/tabs/types.ts +88 -0
  277. package/src/ui/tabs/variants.ts +70 -0
  278. package/src/ui/toast/animated/animations.ts +17 -0
  279. package/src/ui/toast/animated/index.ts +9 -0
  280. package/src/ui/toast/animated/toast-animated.tsx +96 -0
  281. package/src/ui/toast/animated/types.ts +13 -0
  282. package/src/ui/toast/index.ts +26 -0
  283. package/src/ui/toast/toast-base.tsx +231 -0
  284. package/src/ui/toast/toast.test.tsx +102 -0
  285. package/src/ui/toast/toast.tsx +13 -0
  286. package/src/ui/toast/types.ts +57 -0
  287. package/src/ui/toast/variants.ts +73 -0
  288. package/src/ui/toggle/animated/animations.ts +9 -0
  289. package/src/ui/toggle/animated/index.ts +7 -0
  290. package/src/ui/toggle/animated/toggle-animated.tsx +76 -0
  291. package/src/ui/toggle/animated/types.ts +13 -0
  292. package/src/ui/toggle/index.ts +5 -0
  293. package/src/ui/toggle/toggle-base.tsx +70 -0
  294. package/src/ui/toggle/toggle.test.tsx +44 -0
  295. package/src/ui/toggle/toggle.tsx +9 -0
  296. package/src/ui/toggle/types.ts +18 -0
  297. package/src/ui/toggle/variants.ts +84 -0
  298. package/src/ui/tooltip/animated/animations.ts +16 -0
  299. package/src/ui/tooltip/animated/index.ts +10 -0
  300. package/src/ui/tooltip/animated/tooltip-content-animated.tsx +47 -0
  301. package/src/ui/tooltip/animated/types.ts +19 -0
  302. package/src/ui/tooltip/index.ts +17 -0
  303. package/src/ui/tooltip/tooltip-base.tsx +152 -0
  304. package/src/ui/tooltip/tooltip.test.tsx +84 -0
  305. package/src/ui/tooltip/tooltip.tsx +8 -0
  306. package/src/ui/tooltip/types.ts +57 -0
  307. package/src/ui/tooltip/variants.ts +61 -0
@@ -0,0 +1,728 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent as ReactKeyboardEvent,
11
+ type PointerEvent as ReactPointerEvent,
12
+ type Ref,
13
+ type RefObject,
14
+ } from "react";
15
+
16
+ import { cn } from "../../lib/utils";
17
+
18
+ import type {
19
+ RangeSliderProps,
20
+ SliderCtx,
21
+ SliderProps,
22
+ SliderRangeProps,
23
+ SliderThumbProps,
24
+ SliderTrackProps,
25
+ } from "./types";
26
+ import {
27
+ sliderRangeVariants,
28
+ sliderRootVariants,
29
+ sliderThumbVariants,
30
+ sliderTrackVariants,
31
+ } from "./variants";
32
+
33
+ /**
34
+ * Restricts a numeric value to the inclusive interval [min, max].
35
+ *
36
+ * Values below `min` become `min`; values above `max` become `max`.
37
+ * This is used after pointer math and keyboard deltas so the slider
38
+ * never reports values outside the configured domain.
39
+ *
40
+ * Examples (min = 0, max = 100):
41
+ * - clamp(120, 0, 100) → 100 (capped at max)
42
+ * - clamp(-10, 0, 100) → 0 (raised to min)
43
+ * - clamp(42, 0, 100) → 42 (unchanged)
44
+ */
45
+ const clamp = (value: number, min: number, max: number) => {
46
+ return Math.min(max, Math.max(min, value));
47
+ };
48
+
49
+ /**
50
+ * Maps an arbitrary number to the closest value aligned with `step`
51
+ * relative to `min` (the “anchor” of the step grid).
52
+ *
53
+ * How it works:
54
+ * 1. Measure how far `value` is from `min` in “step units”:
55
+ * (value - min) / step
56
+ * 2. Round to the nearest whole number of steps.
57
+ * 3. Reconstruct: min + (rounded steps) * step
58
+ *
59
+ * Example: min = 0, step = 10, value = 23
60
+ * - steps = round((23 - 0) / 10) = round(2.3) = 2
61
+ * - result = 0 + 2 * 10 = 20
62
+ *
63
+ * Note: This does not enforce [min, max]; pair with `clamp` when needed.
64
+ */
65
+ const snapToStep = (value: number, min: number, step: number) => {
66
+ const steps = Math.round((value - min) / step);
67
+ return min + steps * step;
68
+ };
69
+
70
+ const SliderContext = createContext<SliderCtx | null>(null);
71
+
72
+ /**
73
+ * Reads slider context; throws a descriptive error if a sub-part is used
74
+ * outside `<Slider>` (helps catch invalid composition early).
75
+ */
76
+ const useSliderContext = (component: string): SliderCtx => {
77
+ const ctx = useContext(SliderContext);
78
+ if (!ctx) {
79
+ throw new Error(`${component} must be used within <Slider>`);
80
+ }
81
+ return ctx;
82
+ };
83
+
84
+ /**
85
+ * Converts a horizontal pointer position (viewport X) into a slider value
86
+ * in [min, max], snapped to `step`.
87
+ *
88
+ * Pipeline:
89
+ * 1. Read the track’s bounding box so all math uses the same coordinate
90
+ * space as `clientX` (viewport coordinates from pointer events).
91
+ * 2. Compute how far along the track the pointer sits as a ratio in [0, 1]:
92
+ * (clientX - trackLeft) / trackWidth. If width is 0, ratio is 0 to avoid
93
+ * division by zero.
94
+ * 3. Linearly interpolate that ratio into the value domain:
95
+ * raw = min + ratio * (max - min).
96
+ * 4. Clamp `raw` to [min, max], then snap to the nearest valid step so the
97
+ * result always matches discrete thumb positions.
98
+ *
99
+ * Worked example: track left = 100px, width = 200px, min = 0, max = 100,
100
+ * pointer clientX = 150px
101
+ * - ratio = (150 - 100) / 200 = 0.25
102
+ * - raw = 0 + 0.25 * (100 - 0) = 25
103
+ * - After clamp + snap (e.g. step 1), result stays 25
104
+ */
105
+ const computeValueFromPointer = (
106
+ clientX: number,
107
+ track: HTMLDivElement,
108
+ min: number,
109
+ max: number,
110
+ step: number,
111
+ ) => {
112
+ const rect = track.getBoundingClientRect();
113
+
114
+ const ratio = rect.width === 0 ? 0 : (clientX - rect.left) / rect.width;
115
+
116
+ const raw = min + ratio * (max - min);
117
+
118
+ return snapToStep(clamp(raw, min, max), min, step);
119
+ };
120
+
121
+ /**
122
+ * Root primitive for a single-value slider. Provides context (bounds, value,
123
+ * disabled state, track ref) to `SliderTrack`, `SliderRange`, and `SliderThumb`.
124
+ */
125
+ export function Slider({
126
+ className,
127
+ size = "md",
128
+ min = 0,
129
+ max = 100,
130
+ step = 1,
131
+ value: valueProp,
132
+ defaultValue,
133
+ onValueChange,
134
+ disabled = false,
135
+ appearance = "default",
136
+ "aria-label": ariaLabel,
137
+ "aria-labelledby": ariaLabelledBy,
138
+ children,
139
+ ref,
140
+ ...rest
141
+ }: SliderProps & { ref?: Ref<HTMLDivElement> }) {
142
+ const trackRef = useRef<HTMLDivElement | null>(null);
143
+ const isControlled = valueProp !== undefined;
144
+
145
+ /**
146
+ * Mode selection (React’s standard controlled/uncontrolled pattern):
147
+ *
148
+ * - Controlled: `value` is passed from the parent. The parent is the source
149
+ * of truth; local state is not updated on drag. Use `onValueChange` to
150
+ * persist updates upward (e.g. into React state or a form library).
151
+ *
152
+ * - Uncontrolled: `value` is omitted. `defaultValue` (or `min` if absent)
153
+ * seeds internal state; drags and keyboard updates write to that state
154
+ * directly. `onValueChange` is still optional for side effects.
155
+ *
156
+ * Examples:
157
+ * - Controlled: `<Slider value={50} onValueChange={setX} />`
158
+ * - Uncontrolled: `<Slider defaultValue={50} />`
159
+ */
160
+
161
+ const [uncontrolled, setUncontrolled] = useState(defaultValue ?? min);
162
+
163
+ const value = isControlled ? (valueProp as number) : uncontrolled;
164
+
165
+ const setValue = useCallback(
166
+ (next: number) => {
167
+ /**
168
+ * Normalizes a candidate value before committing it:
169
+ * 1. Snap to the nearest step anchored at `min`.
170
+ * 2. Clamp into [min, max] so overshoots from keyboard or programmatic
171
+ * calls cannot escape the domain.
172
+ * 3. If uncontrolled, mirror the result into local state so the thumb
173
+ * and range visuals stay in sync.
174
+ * 4. Always invoke `onValueChange` when provided so parents receive the
175
+ * canonical value (even in controlled mode, after normalization).
176
+ *
177
+ * Example: min = 0, max = 100, step = 10, next = 27
178
+ * → snap to 30 → clamp still 30 → emitted value 30
179
+ */
180
+
181
+ const clamped = clamp(snapToStep(next, min, step), min, max);
182
+
183
+ if (!isControlled) {
184
+ setUncontrolled(clamped);
185
+ }
186
+
187
+ onValueChange?.(clamped);
188
+ },
189
+ [isControlled, max, min, onValueChange, step],
190
+ );
191
+
192
+ const ctx = useMemo(
193
+ () => ({
194
+ min,
195
+ max,
196
+ step,
197
+
198
+ /**
199
+ * Context consumers always see a value that is on-step and in-range.
200
+ * If a controlled parent passes an out-of-band number (e.g. stale props
201
+ * or a bug), we still render thumbs and ARIA attributes consistently
202
+ * with the same snap/clamp rules as pointer and keyboard input.
203
+ */
204
+ value: clamp(snapToStep(value, min, step), min, max),
205
+
206
+ setValue,
207
+ disabled,
208
+ size: size ?? "md",
209
+ appearance: appearance ?? "default",
210
+ trackRef,
211
+ }),
212
+ [appearance, disabled, max, min, setValue, size, step, value],
213
+ );
214
+
215
+ return (
216
+ <SliderContext.Provider value={ctx}>
217
+ <div
218
+ ref={ref}
219
+ data-slot="slider"
220
+ role="group"
221
+ aria-label={ariaLabel}
222
+ aria-labelledby={ariaLabelledBy}
223
+ className={cn(sliderRootVariants({ size }), className)}
224
+ {...rest}
225
+ >
226
+ {children}
227
+ </div>
228
+ </SliderContext.Provider>
229
+ );
230
+ }
231
+
232
+ Slider.displayName = "Slider";
233
+
234
+ /**
235
+ * The interactive rail whose geometry defines how pointer X maps to values.
236
+ * Assigns the DOM node to context `trackRef` so thumbs can measure it.
237
+ */
238
+ export function SliderTrack({
239
+ className,
240
+ ref: refProp,
241
+ ...rest
242
+ }: SliderTrackProps & { ref?: Ref<HTMLDivElement> }) {
243
+ const { size, trackRef } = useSliderContext("SliderTrack");
244
+
245
+ return (
246
+ <div
247
+ ref={(node) => {
248
+ trackRef.current = node;
249
+ if (typeof refProp === "function") {
250
+ refProp(node);
251
+ } else if (refProp) {
252
+ (refProp as RefObject<HTMLDivElement | null>).current = node;
253
+ }
254
+ }}
255
+ data-slot="slider-track"
256
+ className={cn(sliderTrackVariants({ size }), className)}
257
+ {...rest}
258
+ />
259
+ );
260
+ }
261
+
262
+ SliderTrack.displayName = "SliderTrack";
263
+
264
+ /** Filled portion from the start of the track up to the current value (width %). */
265
+ export function SliderRange({
266
+ className,
267
+ ref,
268
+ ...rest
269
+ }: SliderRangeProps & { ref?: Ref<HTMLDivElement> }) {
270
+ const { min, max, value, appearance } = useSliderContext("SliderRange");
271
+
272
+ /**
273
+ * Percentage along the track (0–100) representing the current value.
274
+ *
275
+ * Formula: ((value - min) / (max - min)) * 100. When min === max the range
276
+ * is degenerate; we treat the percentage as 0 to avoid NaN.
277
+ *
278
+ * For the single-thumb slider, this percentage becomes the **width** of
279
+ * the filled range segment (from the start of the track to the thumb).
280
+ * The thumb itself uses the same mapping in `SliderThumb` for `left`.
281
+ */
282
+ const pct = max === min ? 0 : ((value - min) / (max - min)) * 100;
283
+
284
+ return (
285
+ <div
286
+ ref={ref}
287
+ data-slot="slider-range"
288
+ className={cn(sliderRangeVariants({ appearance }), className)}
289
+ style={{ width: `${pct}%` }}
290
+ {...rest}
291
+ />
292
+ );
293
+ }
294
+
295
+ SliderRange.displayName = "SliderRange";
296
+
297
+ /**
298
+ * Draggable thumb with ARIA `role="slider"`. Handles pointer drag (via window
299
+ * listeners) and keyboard increments consistent with `min` / `max` / `step`.
300
+ */
301
+ export function SliderThumb({
302
+ className,
303
+ ref: refProp,
304
+ ...rest
305
+ }: SliderThumbProps & { ref?: Ref<HTMLDivElement> }) {
306
+ const { min, max, value, step, setValue, disabled, size, trackRef } =
307
+ useSliderContext("SliderThumb");
308
+ /** Horizontal thumb position; same mapping as `SliderRange` width uses. */
309
+ const pct = max === min ? 0 : ((value - min) / (max - min)) * 100;
310
+
311
+ const onPointerDown = useCallback(
312
+ (event: ReactPointerEvent<HTMLDivElement>) => {
313
+ if (disabled) return;
314
+
315
+ event.preventDefault();
316
+
317
+ const track = trackRef.current;
318
+ if (!track) return;
319
+
320
+ /**
321
+ * Keep receiving pointer events for this pointer id even when the
322
+ * cursor leaves the thumb. Without capture, dragging quickly could
323
+ * “drop” the thumb when the pointer exits the small hit target.
324
+ */
325
+ event.currentTarget.setPointerCapture(event.pointerId);
326
+
327
+ const move = (e: PointerEvent) => {
328
+ /**
329
+ * On each move, project the pointer X onto the track geometry, then
330
+ * run the same snap/clamp pipeline as a click would. `setValue`
331
+ * deduplicates invalid states for controlled parents.
332
+ */
333
+ setValue(computeValueFromPointer(e.clientX, track, min, max, step));
334
+ };
335
+
336
+ const up = () => {
337
+ /** Tear down window listeners once the gesture ends (any button). */
338
+ window.removeEventListener("pointermove", move);
339
+ window.removeEventListener("pointerup", up);
340
+ };
341
+
342
+ window.addEventListener("pointermove", move);
343
+ window.addEventListener("pointerup", up);
344
+ },
345
+ [disabled, max, min, setValue, step, trackRef],
346
+ );
347
+
348
+ const onKeyDown = useCallback(
349
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
350
+ if (disabled) return;
351
+
352
+ /**
353
+ * Page Up / Page Down move by one tenth of the value span (not one
354
+ * tenth of the thumb travel). This scales with custom min/max ranges.
355
+ */
356
+ const big = (max - min) / 10;
357
+
358
+ let delta = 0;
359
+
360
+ /**
361
+ * Keyboard model (WAI-ARIA slider conventions, adapted to our step):
362
+ * - Arrow Right / Up: increase by one `step`.
363
+ * - Arrow Left / Down: decrease by one `step`.
364
+ * - Page Up / Down: coarse adjust by `big` (±10% of range).
365
+ * - Home / End: jump to min or max (bypasses incremental delta).
366
+ * Unrecognized keys are ignored without calling `preventDefault`.
367
+ */
368
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
369
+ delta = step;
370
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
371
+ delta = -step;
372
+ } else if (event.key === "PageUp") {
373
+ delta = big;
374
+ } else if (event.key === "PageDown") {
375
+ delta = -big;
376
+ } else if (event.key === "Home") {
377
+ event.preventDefault();
378
+ setValue(min);
379
+ return;
380
+ } else if (event.key === "End") {
381
+ event.preventDefault();
382
+ setValue(max);
383
+ return;
384
+ } else {
385
+ return;
386
+ }
387
+
388
+ event.preventDefault();
389
+
390
+ /**
391
+ * Apply the accumulated delta on top of the current value; `setValue`
392
+ * performs snap + clamp so the outcome is always valid.
393
+ *
394
+ * Illustration with min = 0, max = 100, step = 10, value = 40:
395
+ * - ArrowRight: delta +10 → candidate 50 → stays 50
396
+ * - ArrowLeft: delta -10 → candidate 30
397
+ * - PageUp: delta +10 (when big = 10) → 50; PageDown → 30
398
+ * - Home / End handled above with direct `setValue(min|max)`
399
+ */
400
+ setValue(value + delta);
401
+ },
402
+ [disabled, max, min, setValue, step, value],
403
+ );
404
+
405
+ return (
406
+ <div
407
+ ref={(node) => {
408
+ if (typeof refProp === "function") {
409
+ refProp(node);
410
+ } else if (refProp) {
411
+ (refProp as RefObject<HTMLDivElement | null>).current = node;
412
+ }
413
+ }}
414
+ role="slider"
415
+ tabIndex={disabled ? -1 : 0}
416
+ data-slot="slider-thumb"
417
+ aria-valuemin={min}
418
+ aria-valuemax={max}
419
+ aria-valuenow={value}
420
+ aria-disabled={disabled || undefined}
421
+ className={cn(
422
+ "absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2",
423
+ sliderThumbVariants({ size }),
424
+ className,
425
+ )}
426
+ style={{ left: `${pct}%` }}
427
+ onPointerDown={onPointerDown}
428
+ onKeyDown={onKeyDown}
429
+ {...rest}
430
+ />
431
+ );
432
+ }
433
+
434
+ SliderThumb.displayName = "SliderThumb";
435
+
436
+ /**
437
+ * Two-thumb range control on one track. Inlines track/range for layout speed;
438
+ * thumbs delegate drag math through `moveThumb` / `setPair` to keep ordering
439
+ * and snapping identical to the single slider helpers.
440
+ */
441
+ export function RangeSlider({
442
+ className,
443
+ size = "md",
444
+ min = 0,
445
+ max = 100,
446
+ step = 1,
447
+ value: valueProp,
448
+ defaultValue,
449
+ onValueChange,
450
+ disabled = false,
451
+ appearance = "default",
452
+ "aria-label": ariaLabel,
453
+ "aria-labelledby": ariaLabelledBy,
454
+ ref,
455
+ ...rest
456
+ }: RangeSliderProps & { ref?: Ref<HTMLDivElement> }) {
457
+ const trackRef = useRef<HTMLDivElement | null>(null);
458
+ const isControlled = valueProp !== undefined;
459
+ const [uncontrolled, setUncontrolled] = useState<[number, number]>(() => {
460
+ const seed = defaultValue ?? [min, max];
461
+
462
+ /**
463
+ * Initial pair: normalize each endpoint independently so both ends sit
464
+ * on the step grid and respect [min, max] before any ordering logic runs.
465
+ *
466
+ * Example: min = 0, max = 100, step = 10, seed[0] = 85
467
+ * → snap to 80 → clamp still 80.
468
+ */
469
+ const lo = clamp(snapToStep(seed[0], min, step), min, max);
470
+ const hi = clamp(snapToStep(seed[1], min, step), min, max);
471
+
472
+ /**
473
+ * Canonical order is always [lower, higher]. If the consumer passes
474
+ * reversed defaults (e.g. [80, 20]), we swap so downstream math assumes
475
+ * lo ≤ hi for range width and thumb assignment.
476
+ */
477
+ return lo <= hi ? [lo, hi] : [hi, lo];
478
+ });
479
+
480
+ const value = isControlled ? (valueProp as [number, number]) : uncontrolled;
481
+
482
+ /**
483
+ * Derive ordered endpoints for rendering and hit-testing math. Controlled
484
+ * parents might briefly pass reversed tuples; we normalize every render so
485
+ * `lo` is the left/low thumb and `hi` is the right/high thumb.
486
+ */
487
+ const [lo, hi] =
488
+ value[0] <= value[1] ? [value[0], value[1]] : [value[1], value[0]];
489
+
490
+ /**
491
+ * Commits a new [low, high] pair: used by drags, keyboard nudges, and
492
+ * internal clamping. Each thumb only mutates its own side, but we always
493
+ * pass through here so both values stay snapped and ordered.
494
+ *
495
+ * Example: current [20, 80], dragging the low thumb
496
+ * → calls like setPair([newLow, 80]); the high endpoint is preserved until
497
+ * the other thumb moves.
498
+ */
499
+ const setPair = useCallback(
500
+ (next: [number, number]) => {
501
+ /**
502
+ * Per-endpoint snap + clamp, same rules as the single slider. Handles
503
+ * overshoot from pointer projection or programmatic updates.
504
+ *
505
+ * Example: min = 0, max = 100, step = 10, next = [27, 85]
506
+ * → [30, 80] after snap/clamp (85 cannot exceed max but also snaps down).
507
+ */
508
+ const a = clamp(snapToStep(next[0], min, step), min, max);
509
+ const b = clamp(snapToStep(next[1], min, step), min, max);
510
+
511
+ /**
512
+ * Re-order after independent normalization so the tuple is always
513
+ * [smaller, larger], keeping the range bar width non-negative.
514
+ */
515
+ const ordered: [number, number] = a <= b ? [a, b] : [b, a];
516
+ if (!isControlled) {
517
+ setUncontrolled(ordered);
518
+ }
519
+ onValueChange?.(ordered);
520
+ },
521
+ [isControlled, max, min, onValueChange, step],
522
+ );
523
+
524
+ const moveThumb = useCallback(
525
+ (index: 0 | 1, clientX: number) => {
526
+ const track = trackRef.current;
527
+ if (!track) return;
528
+
529
+ const raw = computeValueFromPointer(clientX, track, min, max, step);
530
+
531
+ /**
532
+ * Which thumb is active is explicit (`index`), so we only replace that
533
+ * side of the pair and keep the opposite endpoint fixed. `setPair` will
534
+ * still snap/clamp and may collapse the range if thumbs cross (handled
535
+ * by ordering inside `setPair`).
536
+ *
537
+ * index 0: low thumb → `[raw, hi]`
538
+ * index 1: high thumb → `[lo, raw]`
539
+ */
540
+ if (index === 0) {
541
+ setPair([raw, hi]);
542
+ } else {
543
+ setPair([lo, raw]);
544
+ }
545
+ },
546
+ [hi, lo, max, min, setPair, step],
547
+ );
548
+
549
+ /**
550
+ * Map both endpoints to percentages along the track for layout:
551
+ * - `left` on the range fill uses `loPct` (where the selected interval starts).
552
+ * - `width` uses `hiPct - loPct` (how wide the interval is), floored at 0
553
+ * if the thumbs coincide.
554
+ *
555
+ * Example: min = 0, max = 100, lo = 20, hi = 80
556
+ * - loPct = 20%, hiPct = 80%
557
+ * - Range bar: left 20%, width 60%
558
+ */
559
+ const loPct = ((lo - min) / (max - min)) * 100;
560
+ const hiPct = ((hi - min) / (max - min)) * 100;
561
+
562
+ const resolvedSize = size ?? "md";
563
+
564
+ return (
565
+ <div
566
+ ref={ref}
567
+ data-slot="range-slider"
568
+ role="group"
569
+ aria-label={ariaLabel}
570
+ aria-labelledby={ariaLabelledBy}
571
+ aria-valuetext={`${lo} – ${hi}`}
572
+ className={cn(sliderRootVariants({ size: resolvedSize }), className)}
573
+ {...rest}
574
+ >
575
+ <div
576
+ ref={trackRef}
577
+ data-slot="slider-track"
578
+ className={cn(sliderTrackVariants({ size: resolvedSize }), "relative")}
579
+ >
580
+ <div
581
+ data-slot="slider-range"
582
+ className={cn(sliderRangeVariants({ appearance }), "absolute")}
583
+ style={{
584
+ left: `${loPct}%`,
585
+ width: `${Math.max(hiPct - loPct, 0)}%`,
586
+ }}
587
+ />
588
+ <RangeThumb
589
+ disabled={disabled}
590
+ size={resolvedSize}
591
+ value={lo}
592
+ min={min}
593
+ max={max}
594
+ step={step}
595
+ positionPct={loPct}
596
+ trackRef={trackRef}
597
+ onMoveClientX={(x) => moveThumb(0, x)}
598
+ onNudge={(delta) => setPair([lo + delta, hi])}
599
+ />
600
+ <RangeThumb
601
+ disabled={disabled}
602
+ size={resolvedSize}
603
+ value={hi}
604
+ min={min}
605
+ max={max}
606
+ step={step}
607
+ positionPct={hiPct}
608
+ trackRef={trackRef}
609
+ onMoveClientX={(x) => moveThumb(1, x)}
610
+ onNudge={(delta) => setPair([lo, hi + delta])}
611
+ />
612
+ </div>
613
+ </div>
614
+ );
615
+ }
616
+
617
+ RangeSlider.displayName = "RangeSlider";
618
+
619
+ type RangeThumbProps = {
620
+ disabled: boolean;
621
+ size: "sm" | "md" | "lg";
622
+ value: number;
623
+ min: number;
624
+ max: number;
625
+ step: number;
626
+ positionPct: number;
627
+ trackRef: RefObject<HTMLDivElement | null>;
628
+ /** Called on pointer move with viewport X; parent decides which bound updates. */
629
+ onMoveClientX: (clientX: number) => void;
630
+ /** Relative keyboard adjustment in value units; parent merges into the pair. */
631
+ onNudge: (delta: number) => void;
632
+ };
633
+
634
+ /** Private thumb implementation shared by the low and high endpoints. */
635
+ function RangeThumb({
636
+ disabled,
637
+ size,
638
+ value,
639
+ min,
640
+ max,
641
+ step,
642
+ positionPct,
643
+ trackRef,
644
+ onMoveClientX,
645
+ onNudge,
646
+ }: RangeThumbProps) {
647
+ const onPointerDown = useCallback(
648
+ (event: ReactPointerEvent<HTMLDivElement>) => {
649
+ if (disabled) {
650
+ return;
651
+ }
652
+ event.preventDefault();
653
+ const track = trackRef.current;
654
+ if (!track) {
655
+ return;
656
+ }
657
+ /** Same capture strategy as `SliderThumb` for reliable dragging. */
658
+ event.currentTarget.setPointerCapture(event.pointerId);
659
+ const move = (e: PointerEvent) => {
660
+ /** Parent maps X → new value for this thumb only. */
661
+ onMoveClientX(e.clientX);
662
+ };
663
+ const up = () => {
664
+ window.removeEventListener("pointermove", move);
665
+ window.removeEventListener("pointerup", up);
666
+ };
667
+ window.addEventListener("pointermove", move);
668
+ window.addEventListener("pointerup", up);
669
+ },
670
+ [disabled, onMoveClientX, trackRef],
671
+ );
672
+
673
+ const onKeyDown = useCallback(
674
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
675
+ if (disabled) {
676
+ return;
677
+ }
678
+ /**
679
+ * Keyboard deltas mirror the single-thumb slider, but we express jumps
680
+ * as `onNudge(min - value)` / `onNudge(max - value)` so Home/End move
681
+ * **this** thumb to the domain edge without hardcoding sibling values.
682
+ */
683
+ const big = (max - min) / 10;
684
+ let delta = 0;
685
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
686
+ delta = step;
687
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
688
+ delta = -step;
689
+ } else if (event.key === "PageUp") {
690
+ delta = big;
691
+ } else if (event.key === "PageDown") {
692
+ delta = -big;
693
+ } else if (event.key === "Home") {
694
+ event.preventDefault();
695
+ onNudge(min - value);
696
+ return;
697
+ } else if (event.key === "End") {
698
+ event.preventDefault();
699
+ onNudge(max - value);
700
+ return;
701
+ } else {
702
+ return;
703
+ }
704
+ event.preventDefault();
705
+ onNudge(delta);
706
+ },
707
+ [disabled, max, min, onNudge, step, value],
708
+ );
709
+
710
+ return (
711
+ <div
712
+ role="slider"
713
+ tabIndex={disabled ? -1 : 0}
714
+ data-slot="range-slider-thumb"
715
+ aria-valuemin={min}
716
+ aria-valuemax={max}
717
+ aria-valuenow={value}
718
+ aria-disabled={disabled || undefined}
719
+ className={cn(
720
+ "absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2",
721
+ sliderThumbVariants({ size }),
722
+ )}
723
+ style={{ left: `${positionPct}%` }}
724
+ onPointerDown={onPointerDown}
725
+ onKeyDown={onKeyDown}
726
+ />
727
+ );
728
+ }