tekivex-ui 2.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/index.cjs +70 -0
  4. package/dist/index.d.ts +89 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11002 -0
  7. package/dist/src/a11y/index.d.ts +24 -0
  8. package/dist/src/a11y/index.d.ts.map +1 -0
  9. package/dist/src/components/TkxAccordion.d.ts +32 -0
  10. package/dist/src/components/TkxAccordion.d.ts.map +1 -0
  11. package/dist/src/components/TkxAlert.d.ts +14 -0
  12. package/dist/src/components/TkxAlert.d.ts.map +1 -0
  13. package/dist/src/components/TkxAvatar.d.ts +14 -0
  14. package/dist/src/components/TkxAvatar.d.ts.map +1 -0
  15. package/dist/src/components/TkxBadge.d.ts +12 -0
  16. package/dist/src/components/TkxBadge.d.ts.map +1 -0
  17. package/dist/src/components/TkxButton.d.ts +17 -0
  18. package/dist/src/components/TkxButton.d.ts.map +1 -0
  19. package/dist/src/components/TkxCard.d.ts +20 -0
  20. package/dist/src/components/TkxCard.d.ts.map +1 -0
  21. package/dist/src/components/TkxCarousel.d.ts +32 -0
  22. package/dist/src/components/TkxCarousel.d.ts.map +1 -0
  23. package/dist/src/components/TkxChat.d.ts +32 -0
  24. package/dist/src/components/TkxChat.d.ts.map +1 -0
  25. package/dist/src/components/TkxCheckbox.d.ts +13 -0
  26. package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
  27. package/dist/src/components/TkxClock.d.ts +23 -0
  28. package/dist/src/components/TkxClock.d.ts.map +1 -0
  29. package/dist/src/components/TkxColorPicker.d.ts +18 -0
  30. package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
  31. package/dist/src/components/TkxCommand.d.ts +34 -0
  32. package/dist/src/components/TkxCommand.d.ts.map +1 -0
  33. package/dist/src/components/TkxDatePicker.d.ts +47 -0
  34. package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
  35. package/dist/src/components/TkxDivider.d.ts +8 -0
  36. package/dist/src/components/TkxDivider.d.ts.map +1 -0
  37. package/dist/src/components/TkxDrawer.d.ts +16 -0
  38. package/dist/src/components/TkxDrawer.d.ts.map +1 -0
  39. package/dist/src/components/TkxFileUpload.d.ts +16 -0
  40. package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
  41. package/dist/src/components/TkxIcon.d.ts +16 -0
  42. package/dist/src/components/TkxIcon.d.ts.map +1 -0
  43. package/dist/src/components/TkxImage.d.ts +16 -0
  44. package/dist/src/components/TkxImage.d.ts.map +1 -0
  45. package/dist/src/components/TkxInput.d.ts +13 -0
  46. package/dist/src/components/TkxInput.d.ts.map +1 -0
  47. package/dist/src/components/TkxLogo.d.ts +21 -0
  48. package/dist/src/components/TkxLogo.d.ts.map +1 -0
  49. package/dist/src/components/TkxMenu.d.ts +64 -0
  50. package/dist/src/components/TkxMenu.d.ts.map +1 -0
  51. package/dist/src/components/TkxModal.d.ts +14 -0
  52. package/dist/src/components/TkxModal.d.ts.map +1 -0
  53. package/dist/src/components/TkxNumberInput.d.ts +32 -0
  54. package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
  55. package/dist/src/components/TkxOTP.d.ts +24 -0
  56. package/dist/src/components/TkxOTP.d.ts.map +1 -0
  57. package/dist/src/components/TkxPagination.d.ts +19 -0
  58. package/dist/src/components/TkxPagination.d.ts.map +1 -0
  59. package/dist/src/components/TkxProgress.d.ts +13 -0
  60. package/dist/src/components/TkxProgress.d.ts.map +1 -0
  61. package/dist/src/components/TkxRadio.d.ts +22 -0
  62. package/dist/src/components/TkxRadio.d.ts.map +1 -0
  63. package/dist/src/components/TkxRating.d.ts +18 -0
  64. package/dist/src/components/TkxRating.d.ts.map +1 -0
  65. package/dist/src/components/TkxSelect.d.ts +37 -0
  66. package/dist/src/components/TkxSelect.d.ts.map +1 -0
  67. package/dist/src/components/TkxSkeleton.d.ts +12 -0
  68. package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
  69. package/dist/src/components/TkxSlider.d.ts +33 -0
  70. package/dist/src/components/TkxSlider.d.ts.map +1 -0
  71. package/dist/src/components/TkxStepper.d.ts +31 -0
  72. package/dist/src/components/TkxStepper.d.ts.map +1 -0
  73. package/dist/src/components/TkxTable.d.ts +23 -0
  74. package/dist/src/components/TkxTable.d.ts.map +1 -0
  75. package/dist/src/components/TkxTabs.d.ts +32 -0
  76. package/dist/src/components/TkxTabs.d.ts.map +1 -0
  77. package/dist/src/components/TkxTag.d.ts +30 -0
  78. package/dist/src/components/TkxTag.d.ts.map +1 -0
  79. package/dist/src/components/TkxTimeline.d.ts +20 -0
  80. package/dist/src/components/TkxTimeline.d.ts.map +1 -0
  81. package/dist/src/components/TkxToast.d.ts +23 -0
  82. package/dist/src/components/TkxToast.d.ts.map +1 -0
  83. package/dist/src/components/TkxToggle.d.ts +11 -0
  84. package/dist/src/components/TkxToggle.d.ts.map +1 -0
  85. package/dist/src/components/TkxTooltip.d.ts +10 -0
  86. package/dist/src/components/TkxTooltip.d.ts.map +1 -0
  87. package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
  88. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
  89. package/dist/src/components/index.d.ts +41 -0
  90. package/dist/src/components/index.d.ts.map +1 -0
  91. package/dist/src/engine/css.d.ts +24 -0
  92. package/dist/src/engine/css.d.ts.map +1 -0
  93. package/dist/src/engine/index.d.ts +10 -0
  94. package/dist/src/engine/index.d.ts.map +1 -0
  95. package/dist/src/engine/quantum.d.ts +47 -0
  96. package/dist/src/engine/quantum.d.ts.map +1 -0
  97. package/dist/src/engine/security.d.ts +54 -0
  98. package/dist/src/engine/security.d.ts.map +1 -0
  99. package/dist/src/engine/tkx.d.ts +29 -0
  100. package/dist/src/engine/tkx.d.ts.map +1 -0
  101. package/dist/src/engine/wcag.d.ts +34 -0
  102. package/dist/src/engine/wcag.d.ts.map +1 -0
  103. package/dist/src/hooks/index.d.ts +8 -0
  104. package/dist/src/hooks/index.d.ts.map +1 -0
  105. package/dist/src/themes/index.d.ts +26 -0
  106. package/dist/src/themes/index.d.ts.map +1 -0
  107. package/dist/style.css +1 -0
  108. package/dist/tests/TkxButton.test.d.ts +2 -0
  109. package/dist/tests/TkxButton.test.d.ts.map +1 -0
  110. package/dist/tests/a11y/components.test.d.ts +2 -0
  111. package/dist/tests/a11y/components.test.d.ts.map +1 -0
  112. package/dist/tests/quantum.test.d.ts +2 -0
  113. package/dist/tests/quantum.test.d.ts.map +1 -0
  114. package/dist/tests/security.test.d.ts +2 -0
  115. package/dist/tests/security.test.d.ts.map +1 -0
  116. package/dist/tests/setup.d.ts +2 -0
  117. package/dist/tests/setup.d.ts.map +1 -0
  118. package/dist/tests/tkx.test.d.ts +2 -0
  119. package/dist/tests/tkx.test.d.ts.map +1 -0
  120. package/dist/tests/wcag.test.d.ts +2 -0
  121. package/dist/tests/wcag.test.d.ts.map +1 -0
  122. package/package.json +78 -0
  123. package/src/a11y/index.tsx +87 -0
  124. package/src/components/TkxAccordion.tsx +588 -0
  125. package/src/components/TkxAlert.tsx +91 -0
  126. package/src/components/TkxAvatar.tsx +101 -0
  127. package/src/components/TkxBadge.tsx +73 -0
  128. package/src/components/TkxButton.tsx +153 -0
  129. package/src/components/TkxCard.tsx +92 -0
  130. package/src/components/TkxCarousel.tsx +552 -0
  131. package/src/components/TkxChat.tsx +354 -0
  132. package/src/components/TkxCheckbox.tsx +218 -0
  133. package/src/components/TkxClock.tsx +601 -0
  134. package/src/components/TkxColorPicker.tsx +633 -0
  135. package/src/components/TkxCommand.tsx +644 -0
  136. package/src/components/TkxDatePicker.tsx +1567 -0
  137. package/src/components/TkxDivider.tsx +49 -0
  138. package/src/components/TkxDrawer.tsx +234 -0
  139. package/src/components/TkxFileUpload.tsx +371 -0
  140. package/src/components/TkxIcon.tsx +683 -0
  141. package/src/components/TkxImage.tsx +277 -0
  142. package/src/components/TkxInput.tsx +101 -0
  143. package/src/components/TkxLogo.tsx +297 -0
  144. package/src/components/TkxMenu.tsx +1042 -0
  145. package/src/components/TkxModal.tsx +119 -0
  146. package/src/components/TkxNumberInput.tsx +408 -0
  147. package/src/components/TkxOTP.tsx +295 -0
  148. package/src/components/TkxPagination.tsx +388 -0
  149. package/src/components/TkxProgress.tsx +114 -0
  150. package/src/components/TkxRadio.tsx +284 -0
  151. package/src/components/TkxRating.tsx +286 -0
  152. package/src/components/TkxSelect.tsx +923 -0
  153. package/src/components/TkxSkeleton.tsx +105 -0
  154. package/src/components/TkxSlider.tsx +492 -0
  155. package/src/components/TkxStepper.tsx +485 -0
  156. package/src/components/TkxTable.tsx +148 -0
  157. package/src/components/TkxTabs.tsx +124 -0
  158. package/src/components/TkxTag.tsx +319 -0
  159. package/src/components/TkxTimeline.tsx +303 -0
  160. package/src/components/TkxToast.tsx +284 -0
  161. package/src/components/TkxToggle.tsx +93 -0
  162. package/src/components/TkxTooltip.tsx +69 -0
  163. package/src/components/TkxVideoPlayer.tsx +1013 -0
  164. package/src/components/index.ts +40 -0
  165. package/src/engine/css.ts +126 -0
  166. package/src/engine/index.ts +10 -0
  167. package/src/engine/quantum.ts +207 -0
  168. package/src/engine/security.ts +217 -0
  169. package/src/engine/tkx.ts +874 -0
  170. package/src/engine/wcag.ts +275 -0
  171. package/src/hooks/index.ts +120 -0
  172. package/src/styles/global.css +167 -0
  173. package/src/themes/index.ts +111 -0
@@ -0,0 +1,105 @@
1
+ import { forwardRef, type HTMLAttributes } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { useReducedMotion } from '../hooks';
4
+ import { tkx, cx } from '../engine/tkx';
5
+
6
+ export type SkeletonVariant = 'text' | 'circular' | 'rectangular';
7
+ export type SkeletonAnimation = 'pulse' | 'wave' | false;
8
+
9
+ export interface TkxSkeletonProps extends HTMLAttributes<HTMLDivElement> {
10
+ variant?: SkeletonVariant;
11
+ width?: string | number;
12
+ height?: string | number;
13
+ lines?: number;
14
+ animation?: SkeletonAnimation;
15
+ }
16
+
17
+ interface SingleProps {
18
+ variant: SkeletonVariant;
19
+ width?: string | number;
20
+ height?: string | number;
21
+ animation: SkeletonAnimation;
22
+ bgColor: string;
23
+ accentColor: string;
24
+ }
25
+
26
+ function SingleSkeleton({ variant, width, height, animation, bgColor, accentColor }: SingleProps) {
27
+ const w = width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : '100%';
28
+ const defaultH: Record<SkeletonVariant, string> = { text: '1em', circular: w, rectangular: '80px' };
29
+ const h = height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : defaultH[variant];
30
+
31
+ const shapeClass = tkx(variant === 'circular' ? 'rounded-full' : variant === 'text' ? 'rounded' : 'rounded-lg');
32
+ const animClass = animation === 'pulse'
33
+ ? tkx('animate-pulse')
34
+ : animation === 'wave'
35
+ ? tkx('animate-shimmer')
36
+ : '';
37
+
38
+ const waveStyle = animation === 'wave'
39
+ ? { background: `linear-gradient(90deg,${bgColor} 25%,${accentColor} 50%,${bgColor} 75%)`, backgroundSize: '200% 100%' }
40
+ : { backgroundColor: bgColor };
41
+
42
+ return (
43
+ <div
44
+ className={cx(shapeClass, animClass)}
45
+ style={{ width: w, height: h, ...waveStyle }}
46
+ />
47
+ );
48
+ }
49
+
50
+ export const TkxSkeleton = forwardRef<HTMLDivElement, TkxSkeletonProps>(
51
+ ({ variant = 'rectangular', width, height, lines, animation = 'pulse', className, style, ...rest }, ref) => {
52
+ const theme = useTheme();
53
+ const reducedMotion = useReducedMotion();
54
+ const effectiveAnimation = reducedMotion ? false : animation;
55
+
56
+ if (variant === 'text' && lines && lines > 1) {
57
+ return (
58
+ <div
59
+ ref={ref}
60
+ role="progressbar"
61
+ aria-busy="true"
62
+ aria-label="Loading..."
63
+ className={cx(tkx('flex flex-col gap-2'), className)}
64
+ style={style}
65
+ {...rest}
66
+ >
67
+ {Array.from({ length: lines }, (_, i) => (
68
+ <SingleSkeleton
69
+ key={i}
70
+ variant="text"
71
+ width={i === lines - 1 ? '75%' : width}
72
+ height={height}
73
+ animation={effectiveAnimation}
74
+ bgColor={theme.surfaceAlt}
75
+ accentColor={theme.border}
76
+ />
77
+ ))}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <div
84
+ ref={ref}
85
+ role="progressbar"
86
+ aria-busy="true"
87
+ aria-label="Loading..."
88
+ className={className}
89
+ style={style}
90
+ {...rest}
91
+ >
92
+ <SingleSkeleton
93
+ variant={variant}
94
+ width={width}
95
+ height={height}
96
+ animation={effectiveAnimation}
97
+ bgColor={theme.surfaceAlt}
98
+ accentColor={theme.border}
99
+ />
100
+ </div>
101
+ );
102
+ },
103
+ );
104
+
105
+ TkxSkeleton.displayName = 'TkxSkeleton';
@@ -0,0 +1,492 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useId,
5
+ useCallback,
6
+ type PointerEvent as ReactPointerEvent,
7
+ } from 'react';
8
+ import { useTheme } from '../themes';
9
+ import { tkx } from '../engine/tkx';
10
+ import { sanitizeString } from '../engine/security';
11
+ import { useReducedMotion } from '../hooks';
12
+
13
+ export interface TkxSliderProps {
14
+ value?: number;
15
+ defaultValue?: number;
16
+ onChange?: (value: number) => void;
17
+ rangeValue?: [number, number];
18
+ onRangeChange?: (range: [number, number]) => void;
19
+ min?: number;
20
+ max?: number;
21
+ step?: number;
22
+ isRange?: boolean;
23
+ label?: string;
24
+ showValue?: boolean;
25
+ showTicks?: boolean;
26
+ tickCount?: number;
27
+ isDisabled?: boolean;
28
+ colorScheme?: 'primary' | 'success' | 'danger' | 'warning';
29
+ size?: 'sm' | 'md' | 'lg';
30
+ marks?: { value: number; label: string }[];
31
+ orientation?: 'horizontal' | 'vertical';
32
+ showTooltip?: boolean | 'hover' | 'always';
33
+ formatValue?: (value: number) => string;
34
+ onChangeEnd?: (value: number) => void;
35
+ onRangeChangeEnd?: (range: [number, number]) => void;
36
+ gradient?: boolean;
37
+ }
38
+
39
+ const SIZE_MAP = {
40
+ sm: { track: 4, thumb: 14, fontSize: '0.75rem' },
41
+ md: { track: 6, thumb: 18, fontSize: '0.875rem' },
42
+ lg: { track: 8, thumb: 22, fontSize: '1rem' },
43
+ };
44
+
45
+ function clamp(v: number, min: number, max: number): number {
46
+ return Math.min(Math.max(v, min), max);
47
+ }
48
+
49
+ function snapToStep(v: number, min: number, step: number): number {
50
+ return Math.round((v - min) / step) * step + min;
51
+ }
52
+
53
+ function toPercent(v: number, min: number, max: number): number {
54
+ return ((v - min) / (max - min)) * 100;
55
+ }
56
+
57
+ interface ThumbProps {
58
+ value: number;
59
+ min: number;
60
+ max: number;
61
+ step: number;
62
+ isDisabled: boolean;
63
+ trackColor: string;
64
+ trackHeight: number;
65
+ thumbSize: number;
66
+ ariaLabel: string;
67
+ tooltipMode: false | 'hover' | 'always';
68
+ formatValue: (v: number) => string;
69
+ orientation: 'horizontal' | 'vertical';
70
+ onChange: (v: number) => void;
71
+ onChangeEnd?: () => void;
72
+ }
73
+
74
+ function Thumb({ value, min, max, step, isDisabled, trackColor, thumbSize, ariaLabel, tooltipMode, formatValue, orientation, onChange, onChangeEnd }: ThumbProps) {
75
+ const [focused, setFocused] = useState(false);
76
+ const [dragging, setDragging] = useState(false);
77
+ const [hovered, setHovered] = useState(false);
78
+
79
+ const handleKeyDown = (e: React.KeyboardEvent) => {
80
+ if (isDisabled) return;
81
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
82
+ e.preventDefault();
83
+ onChange(clamp(snapToStep(value + step, min, step), min, max));
84
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
85
+ e.preventDefault();
86
+ onChange(clamp(snapToStep(value - step, min, step), min, max));
87
+ } else if (e.key === 'Home') {
88
+ e.preventDefault(); onChange(min);
89
+ } else if (e.key === 'End') {
90
+ e.preventDefault(); onChange(max);
91
+ }
92
+ };
93
+
94
+ const isVertical = orientation === 'vertical';
95
+ const percent = toPercent(value, min, max);
96
+ const showTip =
97
+ tooltipMode === 'always' ||
98
+ (tooltipMode === 'hover' && (hovered || dragging || focused));
99
+
100
+ const positionStyle: React.CSSProperties = isVertical
101
+ ? {
102
+ position: 'absolute',
103
+ bottom: `${percent}%`,
104
+ transform: 'translateY(50%)',
105
+ left: '50%',
106
+ marginLeft: `-${thumbSize / 2}px`,
107
+ zIndex: 2,
108
+ cursor: isDisabled ? 'not-allowed' : 'grab',
109
+ }
110
+ : {
111
+ position: 'absolute',
112
+ left: `${percent}%`,
113
+ transform: 'translateX(-50%)',
114
+ top: '50%',
115
+ marginTop: `-${thumbSize / 2}px`,
116
+ zIndex: 2,
117
+ cursor: isDisabled ? 'not-allowed' : 'grab',
118
+ };
119
+
120
+ const tooltipStyle: React.CSSProperties = isVertical
121
+ ? {
122
+ position: 'absolute',
123
+ left: thumbSize + 6,
124
+ top: '50%',
125
+ transform: 'translateY(-50%)',
126
+ backgroundColor: trackColor,
127
+ color: '#fff',
128
+ borderRadius: '4px',
129
+ padding: '2px 6px',
130
+ fontSize: '0.75rem',
131
+ whiteSpace: 'nowrap',
132
+ pointerEvents: 'none',
133
+ }
134
+ : {
135
+ position: 'absolute',
136
+ bottom: thumbSize + 6,
137
+ left: '50%',
138
+ transform: 'translateX(-50%)',
139
+ backgroundColor: trackColor,
140
+ color: '#fff',
141
+ borderRadius: '4px',
142
+ padding: '2px 6px',
143
+ fontSize: '0.75rem',
144
+ whiteSpace: 'nowrap',
145
+ pointerEvents: 'none',
146
+ };
147
+
148
+ return (
149
+ <div style={positionStyle}>
150
+ {showTip && (
151
+ <div style={tooltipStyle}>
152
+ {formatValue(value)}
153
+ </div>
154
+ )}
155
+ <div
156
+ role="slider"
157
+ tabIndex={isDisabled ? -1 : 0}
158
+ aria-label={ariaLabel}
159
+ aria-valuemin={min}
160
+ aria-valuemax={max}
161
+ aria-valuenow={value}
162
+ aria-disabled={isDisabled}
163
+ aria-orientation={orientation}
164
+ onKeyDown={handleKeyDown}
165
+ onFocus={() => setFocused(true)}
166
+ onBlur={() => setFocused(false)}
167
+ onPointerEnter={() => setHovered(true)}
168
+ onPointerLeave={() => setHovered(false)}
169
+ onPointerDown={(e) => { if (!isDisabled) { setDragging(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }}}
170
+ onPointerUp={() => { setDragging(false); onChangeEnd?.(); }}
171
+ style={{
172
+ width: thumbSize,
173
+ height: thumbSize,
174
+ borderRadius: '50%',
175
+ backgroundColor: isDisabled ? '#888' : trackColor,
176
+ border: `3px solid #fff`,
177
+ boxShadow: focused ? `0 0 0 3px ${trackColor}44` : '0 1px 4px rgba(0,0,0,0.3)',
178
+ outline: 'none',
179
+ display: 'block',
180
+ transition: 'box-shadow 150ms ease',
181
+ }}
182
+ />
183
+ </div>
184
+ );
185
+ }
186
+
187
+ export function TkxSlider({
188
+ value,
189
+ defaultValue = 0,
190
+ onChange,
191
+ rangeValue,
192
+ onRangeChange,
193
+ min = 0,
194
+ max = 100,
195
+ step = 1,
196
+ isRange = false,
197
+ label,
198
+ showValue = false,
199
+ showTicks = false,
200
+ tickCount = 5,
201
+ isDisabled = false,
202
+ colorScheme = 'primary',
203
+ size = 'md',
204
+ marks,
205
+ orientation = 'horizontal',
206
+ showTooltip = false,
207
+ formatValue: formatValueProp,
208
+ onChangeEnd,
209
+ onRangeChangeEnd,
210
+ gradient = false,
211
+ }: TkxSliderProps) {
212
+ const theme = useTheme();
213
+ const id = useId();
214
+ const reducedMotion = useReducedMotion();
215
+
216
+ const isControlled = value !== undefined;
217
+ const [internalValue, setInternalValue] = useState(defaultValue);
218
+ const singleValue = isControlled ? value! : internalValue;
219
+
220
+ const isRangeControlled = rangeValue !== undefined;
221
+ const [internalRange, setInternalRange] = useState<[number, number]>([min, max]);
222
+ const rangeVal = isRangeControlled ? rangeValue! : internalRange;
223
+
224
+ const trackRef = useRef<HTMLDivElement>(null);
225
+ const singleValueRef = useRef(singleValue);
226
+ singleValueRef.current = singleValue;
227
+ const rangeValRef = useRef(rangeVal);
228
+ rangeValRef.current = rangeVal;
229
+
230
+ const trackColor = {
231
+ primary: theme.primary,
232
+ success: theme.success,
233
+ danger: theme.danger,
234
+ warning: theme.warning,
235
+ }[colorScheme];
236
+
237
+ const sizes = SIZE_MAP[size];
238
+ const isVertical = orientation === 'vertical';
239
+
240
+ const tooltipMode: false | 'hover' | 'always' =
241
+ showTooltip === true ? 'hover' :
242
+ showTooltip === false ? false :
243
+ showTooltip; // 'hover' | 'always'
244
+
245
+ const formatVal = formatValueProp ?? ((v: number) => String(v));
246
+
247
+ const trackColorLight = trackColor + '66'; // 40% opacity variant for gradient end
248
+
249
+ const getValueFromPointer = useCallback((clientX: number, clientY?: number): number => {
250
+ const track = trackRef.current;
251
+ if (!track) return min;
252
+ const rect = track.getBoundingClientRect();
253
+ let ratio: number;
254
+ if (isVertical) {
255
+ // vertical: bottom = min, top = max
256
+ ratio = clamp((rect.bottom - (clientY ?? 0)) / rect.height, 0, 1);
257
+ } else {
258
+ ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
259
+ }
260
+ const raw = ratio * (max - min) + min;
261
+ return clamp(snapToStep(raw, min, step), min, max);
262
+ }, [min, max, step, isVertical]);
263
+
264
+ const handleTrackPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
265
+ if (isDisabled) return;
266
+ const v = getValueFromPointer(e.clientX, e.clientY);
267
+
268
+ if (!isRange) {
269
+ if (!isControlled) setInternalValue(v);
270
+ onChange?.(v);
271
+ } else {
272
+ const [lo, hi] = rangeVal;
273
+ const distLo = Math.abs(v - lo);
274
+ const distHi = Math.abs(v - hi);
275
+ const newRange: [number, number] = distLo <= distHi ? [v, hi] : [lo, v];
276
+ if (!isRangeControlled) setInternalRange(newRange);
277
+ onRangeChange?.(newRange);
278
+ }
279
+ }, [isDisabled, isRange, isControlled, isRangeControlled, getValueFromPointer, onChange, onRangeChange, rangeVal]);
280
+
281
+ const setSingleValue = (v: number) => {
282
+ if (!isControlled) setInternalValue(v);
283
+ onChange?.(v);
284
+ };
285
+
286
+ const setRangeStart = (v: number) => {
287
+ const newRange: [number, number] = [clamp(v, min, rangeVal[1]), rangeVal[1]];
288
+ if (!isRangeControlled) setInternalRange(newRange);
289
+ onRangeChange?.(newRange);
290
+ };
291
+
292
+ const setRangeEnd = (v: number) => {
293
+ const newRange: [number, number] = [rangeVal[0], clamp(v, rangeVal[0], max)];
294
+ if (!isRangeControlled) setInternalRange(newRange);
295
+ onRangeChange?.(newRange);
296
+ };
297
+
298
+ const fillLeft = isRange ? toPercent(rangeVal[0], min, max) : 0;
299
+ const fillRight = isRange ? toPercent(rangeVal[1], min, max) : toPercent(singleValue, min, max);
300
+
301
+ const ticks = showTicks
302
+ ? Array.from({ length: tickCount }, (_, i) => min + (i / (tickCount - 1)) * (max - min))
303
+ : [];
304
+
305
+ const safeLabel = label ? sanitizeString(label) : undefined;
306
+ const transition = reducedMotion ? 'none' : 'background 150ms ease';
307
+
308
+ return (
309
+ <div className={tkx('flex flex-col gap-2')} style={{ opacity: isDisabled ? 0.55 : 1, ...(isVertical ? { height: 200, width: 'auto', display: 'inline-flex' } : { width: '100%' }) }}>
310
+ {(safeLabel || showValue) && (
311
+ <div className={tkx('flex items-center justify-between')}>
312
+ {safeLabel && (
313
+ <label
314
+ htmlFor={id}
315
+ className={tkx('text-sm font-medium')}
316
+ style={{ color: theme.text, fontSize: sizes.fontSize }}
317
+ >
318
+ {safeLabel}
319
+ </label>
320
+ )}
321
+ {showValue && !isRange && (
322
+ <span className={tkx('text-sm tabular-nums')} style={{ color: theme.textMuted, fontSize: sizes.fontSize }}>
323
+ {formatVal(singleValue)}
324
+ </span>
325
+ )}
326
+ {showValue && isRange && (
327
+ <span className={tkx('text-sm tabular-nums')} style={{ color: theme.textMuted, fontSize: sizes.fontSize }}>
328
+ {formatVal(rangeVal[0])} – {formatVal(rangeVal[1])}
329
+ </span>
330
+ )}
331
+ </div>
332
+ )}
333
+
334
+ {/* Track container */}
335
+ <div style={{
336
+ position: 'relative',
337
+ ...(isVertical
338
+ ? { paddingLeft: sizes.thumb / 2, paddingRight: sizes.thumb / 2, flex: 1 }
339
+ : { paddingTop: sizes.thumb / 2, paddingBottom: sizes.thumb / 2 }),
340
+ }}>
341
+ <div
342
+ ref={trackRef}
343
+ onPointerDown={handleTrackPointerDown}
344
+ style={{
345
+ position: 'relative',
346
+ ...(isVertical
347
+ ? { width: sizes.track, height: '100%' }
348
+ : { height: sizes.track }),
349
+ borderRadius: 9999,
350
+ backgroundColor: theme.border,
351
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
352
+ userSelect: 'none',
353
+ }}
354
+ >
355
+ {/* Fill */}
356
+ <div
357
+ style={{
358
+ position: 'absolute',
359
+ ...(isVertical
360
+ ? { left: 0, bottom: `${fillLeft}%`, height: `${fillRight - fillLeft}%`, width: '100%' }
361
+ : { top: 0, left: `${fillLeft}%`, width: `${fillRight - fillLeft}%`, height: '100%' }),
362
+ borderRadius: 9999,
363
+ ...(isDisabled
364
+ ? { backgroundColor: theme.textMuted }
365
+ : gradient
366
+ ? { background: isVertical
367
+ ? `linear-gradient(to top, ${trackColorLight}, ${trackColor})`
368
+ : `linear-gradient(to right, ${trackColorLight}, ${trackColor})` }
369
+ : { backgroundColor: trackColor }),
370
+ transition,
371
+ }}
372
+ />
373
+
374
+ {/* Tick marks */}
375
+ {ticks.map((t, i) => (
376
+ <div
377
+ key={i}
378
+ style={{
379
+ position: 'absolute',
380
+ ...(isVertical
381
+ ? { bottom: `${toPercent(t, min, max)}%`, left: '50%', transform: 'translate(-50%, 50%)', height: 3, width: sizes.track + 4 }
382
+ : { left: `${toPercent(t, min, max)}%`, top: '50%', transform: 'translate(-50%, -50%)', width: 3, height: sizes.track + 4 }),
383
+ backgroundColor: theme.surface,
384
+ borderRadius: 9999,
385
+ pointerEvents: 'none',
386
+ }}
387
+ />
388
+ ))}
389
+
390
+ {/* Thumbs */}
391
+ {!isRange ? (
392
+ <Thumb
393
+ value={singleValue}
394
+ min={min}
395
+ max={max}
396
+ step={step}
397
+ isDisabled={isDisabled}
398
+ trackColor={trackColor}
399
+ trackHeight={sizes.track}
400
+ thumbSize={sizes.thumb}
401
+ ariaLabel={safeLabel ?? 'Slider'}
402
+ tooltipMode={tooltipMode}
403
+ formatValue={formatVal}
404
+ orientation={orientation}
405
+ onChange={setSingleValue}
406
+ onChangeEnd={onChangeEnd ? () => onChangeEnd(singleValueRef.current) : undefined}
407
+ />
408
+ ) : (
409
+ <>
410
+ <Thumb
411
+ value={rangeVal[0]}
412
+ min={min}
413
+ max={rangeVal[1]}
414
+ step={step}
415
+ isDisabled={isDisabled}
416
+ trackColor={trackColor}
417
+ trackHeight={sizes.track}
418
+ thumbSize={sizes.thumb}
419
+ ariaLabel={`${safeLabel ?? 'Range'} start`}
420
+ tooltipMode={tooltipMode}
421
+ formatValue={formatVal}
422
+ orientation={orientation}
423
+ onChange={setRangeStart}
424
+ onChangeEnd={onRangeChangeEnd ? () => onRangeChangeEnd(rangeValRef.current) : undefined}
425
+ />
426
+ <Thumb
427
+ value={rangeVal[1]}
428
+ min={rangeVal[0]}
429
+ max={max}
430
+ step={step}
431
+ isDisabled={isDisabled}
432
+ trackColor={trackColor}
433
+ trackHeight={sizes.track}
434
+ thumbSize={sizes.thumb}
435
+ ariaLabel={`${safeLabel ?? 'Range'} end`}
436
+ tooltipMode={tooltipMode}
437
+ formatValue={formatVal}
438
+ orientation={orientation}
439
+ onChange={setRangeEnd}
440
+ onChangeEnd={onRangeChangeEnd ? () => onRangeChangeEnd(rangeValRef.current) : undefined}
441
+ />
442
+ </>
443
+ )}
444
+ </div>
445
+
446
+ {/* Mark labels */}
447
+ {marks && marks.length > 0 && (
448
+ <div style={{ position: isVertical ? 'absolute' : 'relative', ...(isVertical ? { left: '100%', top: 0, bottom: 0, marginLeft: 8 } : { marginTop: 8 }) }}>
449
+ {marks.map((mark, i) => (
450
+ <div
451
+ key={i}
452
+ style={{
453
+ position: 'absolute',
454
+ ...(isVertical
455
+ ? { bottom: `${toPercent(mark.value, min, max)}%`, transform: 'translateY(50%)', left: 0 }
456
+ : { left: `${toPercent(mark.value, min, max)}%`, transform: 'translateX(-50%)' }),
457
+ fontSize: '0.6875rem',
458
+ color: theme.textMuted,
459
+ whiteSpace: 'nowrap',
460
+ }}
461
+ >
462
+ {sanitizeString(mark.label)}
463
+ </div>
464
+ ))}
465
+ </div>
466
+ )}
467
+ </div>
468
+
469
+ {/* Tick labels when no marks */}
470
+ {showTicks && !marks && !isVertical && (
471
+ <div style={{ position: 'relative', height: '1rem' }}>
472
+ {ticks.map((t, i) => (
473
+ <span
474
+ key={i}
475
+ style={{
476
+ position: 'absolute',
477
+ left: `${toPercent(t, min, max)}%`,
478
+ transform: 'translateX(-50%)',
479
+ fontSize: '0.6875rem',
480
+ color: theme.textMuted,
481
+ }}
482
+ >
483
+ {formatVal(Math.round(t))}
484
+ </span>
485
+ ))}
486
+ </div>
487
+ )}
488
+ </div>
489
+ );
490
+ }
491
+
492
+ TkxSlider.displayName = 'TkxSlider';