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,633 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useRef,
5
+ useCallback,
6
+ type CSSProperties,
7
+ type ChangeEvent,
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import { useTheme } from '../themes';
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────────────────
13
+
14
+ export type ColorFormat = 'hex' | 'rgb' | 'hsl';
15
+
16
+ export interface TkxColorPickerProps {
17
+ value?: string;
18
+ defaultValue?: string;
19
+ onChange?: (value: string, format: ColorFormat) => void;
20
+ format?: ColorFormat;
21
+ showAlpha?: boolean;
22
+ presets?: string[];
23
+ disabled?: boolean;
24
+ size?: 'sm' | 'md' | 'lg';
25
+ placeholder?: string;
26
+ label?: string;
27
+ style?: CSSProperties;
28
+ className?: string;
29
+ }
30
+
31
+ // ── Color math ────────────────────────────────────────────────────────────────
32
+
33
+ function hexToHsl(hex: string): [number, number, number] {
34
+ let r = 0, g = 0, b = 0;
35
+ const clean = hex.replace('#', '');
36
+ if (clean.length === 3) {
37
+ r = parseInt(clean[0] + clean[0], 16);
38
+ g = parseInt(clean[1] + clean[1], 16);
39
+ b = parseInt(clean[2] + clean[2], 16);
40
+ } else if (clean.length >= 6) {
41
+ r = parseInt(clean.slice(0, 2), 16);
42
+ g = parseInt(clean.slice(2, 4), 16);
43
+ b = parseInt(clean.slice(4, 6), 16);
44
+ }
45
+ r /= 255; g /= 255; b /= 255;
46
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
47
+ let h = 0, s = 0;
48
+ const l = (max + min) / 2;
49
+ if (max !== min) {
50
+ const d = max - min;
51
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
52
+ switch (max) {
53
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
54
+ case g: h = ((b - r) / d + 2) / 6; break;
55
+ case b: h = ((r - g) / d + 4) / 6; break;
56
+ }
57
+ }
58
+ return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
59
+ }
60
+
61
+ function hslToHex(h: number, s: number, l: number): string {
62
+ s /= 100; l /= 100;
63
+ const a = s * Math.min(l, 1 - l);
64
+ const f = (n: number) => {
65
+ const k = (n + h / 30) % 12;
66
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
67
+ return Math.round(255 * color).toString(16).padStart(2, '0');
68
+ };
69
+ return `#${f(0)}${f(8)}${f(4)}`;
70
+ }
71
+
72
+ function hexToRgb(hex: string): [number, number, number] {
73
+ const clean = hex.replace('#', '');
74
+ const r = parseInt(clean.slice(0, 2), 16);
75
+ const g = parseInt(clean.slice(2, 4), 16);
76
+ const b = parseInt(clean.slice(4, 6), 16);
77
+ return [isNaN(r) ? 0 : r, isNaN(g) ? 0 : g, isNaN(b) ? 0 : b];
78
+ }
79
+
80
+ function rgbToHex(r: number, g: number, b: number): string {
81
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
82
+ }
83
+
84
+ function isValidHex(hex: string): boolean {
85
+ return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex);
86
+ }
87
+
88
+ function formatColor(hex: string, alpha: number, format: ColorFormat): string {
89
+ if (format === 'hex') return alpha < 1 ? `${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}` : hex;
90
+ const [r, g, b] = hexToRgb(hex);
91
+ if (format === 'rgb') return alpha < 1 ? `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(2)})` : `rgb(${r}, ${g}, ${b})`;
92
+ const [h, s, l] = hexToHsl(hex);
93
+ if (format === 'hsl') return alpha < 1 ? `hsla(${h}, ${s}%, ${l}%, ${alpha.toFixed(2)})` : `hsl(${h}, ${s}%, ${l}%)`;
94
+ return hex;
95
+ }
96
+
97
+ // ── Default presets ───────────────────────────────────────────────────────────
98
+
99
+ const DEFAULT_PRESETS = [
100
+ '#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4',
101
+ '#3b82f6', '#8b5cf6', '#ec4899', '#ffffff', '#94a3b8',
102
+ '#475569', '#0f172a',
103
+ ];
104
+
105
+ // ── Saturation/Brightness picker canvas ──────────────────────────────────────
106
+
107
+ function SatBrightPicker({
108
+ hue,
109
+ sat,
110
+ bright,
111
+ onChange,
112
+ }: {
113
+ hue: number;
114
+ sat: number;
115
+ bright: number;
116
+ onChange: (s: number, b: number) => void;
117
+ }) {
118
+ const canvasRef = useRef<HTMLDivElement>(null);
119
+ const dragging = useRef(false);
120
+
121
+ const pick = useCallback((e: MouseEvent | React.MouseEvent) => {
122
+ const el = canvasRef.current;
123
+ if (!el) return;
124
+ const rect = el.getBoundingClientRect();
125
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
126
+ const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
127
+ onChange(Math.round(x * 100), Math.round((1 - y) * 100));
128
+ }, [onChange]);
129
+
130
+ useEffect(() => {
131
+ const onMove = (e: MouseEvent) => { if (dragging.current) pick(e); };
132
+ const onUp = () => { dragging.current = false; };
133
+ document.addEventListener('mousemove', onMove);
134
+ document.addEventListener('mouseup', onUp);
135
+ return () => {
136
+ document.removeEventListener('mousemove', onMove);
137
+ document.removeEventListener('mouseup', onUp);
138
+ };
139
+ }, [pick]);
140
+
141
+ const thumbX = `${sat}%`;
142
+ const thumbY = `${100 - bright}%`;
143
+
144
+ return (
145
+ <div
146
+ ref={canvasRef}
147
+ style={{
148
+ position: 'relative',
149
+ width: '100%',
150
+ height: '160px',
151
+ borderRadius: '6px',
152
+ cursor: 'crosshair',
153
+ background: `hsl(${hue}, 100%, 50%)`,
154
+ flexShrink: 0,
155
+ }}
156
+ onMouseDown={(e) => { dragging.current = true; pick(e); }}
157
+ >
158
+ {/* White gradient left→right */}
159
+ <div style={{
160
+ position: 'absolute', inset: 0, borderRadius: '6px',
161
+ background: 'linear-gradient(to right, #ffffff, transparent)',
162
+ }} />
163
+ {/* Black gradient top→bottom */}
164
+ <div style={{
165
+ position: 'absolute', inset: 0, borderRadius: '6px',
166
+ background: 'linear-gradient(to bottom, transparent, #000000)',
167
+ }} />
168
+ {/* Thumb */}
169
+ <div style={{
170
+ position: 'absolute',
171
+ left: thumbX,
172
+ top: thumbY,
173
+ transform: 'translate(-50%, -50%)',
174
+ width: '14px',
175
+ height: '14px',
176
+ borderRadius: '50%',
177
+ border: '2px solid white',
178
+ boxShadow: '0 0 0 1px rgba(0,0,0,0.4)',
179
+ pointerEvents: 'none',
180
+ backgroundColor: hslToHex(hue, sat, bright / 2 + 25),
181
+ }} />
182
+ </div>
183
+ );
184
+ }
185
+
186
+ // ── Hue slider ────────────────────────────────────────────────────────────────
187
+
188
+ function HueSlider({ hue, onChange }: { hue: number; onChange: (h: number) => void }) {
189
+ const trackRef = useRef<HTMLDivElement>(null);
190
+ const dragging = useRef(false);
191
+
192
+ const pick = useCallback((e: MouseEvent | React.MouseEvent) => {
193
+ const el = trackRef.current;
194
+ if (!el) return;
195
+ const rect = el.getBoundingClientRect();
196
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
197
+ onChange(Math.round(x * 360));
198
+ }, [onChange]);
199
+
200
+ useEffect(() => {
201
+ const onMove = (e: MouseEvent) => { if (dragging.current) pick(e); };
202
+ const onUp = () => { dragging.current = false; };
203
+ document.addEventListener('mousemove', onMove);
204
+ document.addEventListener('mouseup', onUp);
205
+ return () => {
206
+ document.removeEventListener('mousemove', onMove);
207
+ document.removeEventListener('mouseup', onUp);
208
+ };
209
+ }, [pick]);
210
+
211
+ return (
212
+ <div
213
+ ref={trackRef}
214
+ style={{
215
+ position: 'relative',
216
+ height: '12px',
217
+ borderRadius: '6px',
218
+ cursor: 'pointer',
219
+ background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
220
+ }}
221
+ onMouseDown={(e) => { dragging.current = true; pick(e); }}
222
+ >
223
+ <div style={{
224
+ position: 'absolute',
225
+ left: `${(hue / 360) * 100}%`,
226
+ top: '50%',
227
+ transform: 'translate(-50%, -50%)',
228
+ width: '16px',
229
+ height: '16px',
230
+ borderRadius: '50%',
231
+ border: '2px solid white',
232
+ boxShadow: '0 0 0 1px rgba(0,0,0,0.3)',
233
+ backgroundColor: `hsl(${hue}, 100%, 50%)`,
234
+ pointerEvents: 'none',
235
+ }} />
236
+ </div>
237
+ );
238
+ }
239
+
240
+ // ── Alpha slider ──────────────────────────────────────────────────────────────
241
+
242
+ function AlphaSlider({ alpha, hex, onChange }: { alpha: number; hex: string; onChange: (a: number) => void }) {
243
+ const trackRef = useRef<HTMLDivElement>(null);
244
+ const dragging = useRef(false);
245
+
246
+ const pick = useCallback((e: MouseEvent | React.MouseEvent) => {
247
+ const el = trackRef.current;
248
+ if (!el) return;
249
+ const rect = el.getBoundingClientRect();
250
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
251
+ onChange(Math.round(x * 100) / 100);
252
+ }, [onChange]);
253
+
254
+ useEffect(() => {
255
+ const onMove = (e: MouseEvent) => { if (dragging.current) pick(e); };
256
+ const onUp = () => { dragging.current = false; };
257
+ document.addEventListener('mousemove', onMove);
258
+ document.addEventListener('mouseup', onUp);
259
+ return () => {
260
+ document.removeEventListener('mousemove', onMove);
261
+ document.removeEventListener('mouseup', onUp);
262
+ };
263
+ }, [pick]);
264
+
265
+ const [r, g, b] = hexToRgb(hex);
266
+
267
+ return (
268
+ <div style={{ position: 'relative' }}>
269
+ {/* Checkerboard background */}
270
+ <div style={{
271
+ position: 'absolute', inset: 0, borderRadius: '6px',
272
+ backgroundImage: 'repeating-conic-gradient(#ccc 0% 25%, white 0% 50%)',
273
+ backgroundSize: '8px 8px',
274
+ }} />
275
+ <div
276
+ ref={trackRef}
277
+ style={{
278
+ position: 'relative',
279
+ height: '12px',
280
+ borderRadius: '6px',
281
+ cursor: 'pointer',
282
+ background: `linear-gradient(to right, rgba(${r},${g},${b},0), rgba(${r},${g},${b},1))`,
283
+ }}
284
+ onMouseDown={(e) => { dragging.current = true; pick(e); }}
285
+ >
286
+ <div style={{
287
+ position: 'absolute',
288
+ left: `${alpha * 100}%`,
289
+ top: '50%',
290
+ transform: 'translate(-50%, -50%)',
291
+ width: '16px',
292
+ height: '16px',
293
+ borderRadius: '50%',
294
+ border: '2px solid white',
295
+ boxShadow: '0 0 0 1px rgba(0,0,0,0.3)',
296
+ backgroundColor: `rgba(${r},${g},${b},${alpha})`,
297
+ pointerEvents: 'none',
298
+ }} />
299
+ </div>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ // ── Main component ────────────────────────────────────────────────────────────
305
+
306
+ export function TkxColorPicker({
307
+ value,
308
+ defaultValue = '#3b82f6',
309
+ onChange,
310
+ format = 'hex',
311
+ showAlpha = false,
312
+ presets = DEFAULT_PRESETS,
313
+ disabled = false,
314
+ size = 'md',
315
+ placeholder,
316
+ label,
317
+ style,
318
+ }: TkxColorPickerProps) {
319
+ const theme = useTheme();
320
+ const triggerRef = useRef<HTMLButtonElement>(null);
321
+ const popoverRef = useRef<HTMLDivElement>(null);
322
+ const [isOpen, setIsOpen] = useState(false);
323
+
324
+ const controlled = value !== undefined;
325
+ const [internalHex, setInternalHex] = useState<string>(
326
+ controlled ? (value || defaultValue) : defaultValue
327
+ );
328
+ const hex = controlled ? (value || defaultValue) : internalHex;
329
+
330
+ const [alpha, setAlpha] = useState(1);
331
+ const [hexInput, setHexInput] = useState(hex);
332
+ const [popPos, setPopPos] = useState({ top: 0, left: 0 });
333
+
334
+ const [hue, sat, light] = hexToHsl(isValidHex(hex) ? hex : defaultValue);
335
+ // Convert HSL lightness → brightness for SB picker
336
+ // brightness ≈ lightness + saturation * min(lightness, 1-lightness)
337
+ const bright = Math.round(light + (sat / 100) * Math.min(light / 100, 1 - light / 100) * 100);
338
+
339
+ const updateColor = useCallback((newHex: string, newAlpha = alpha) => {
340
+ if (!isValidHex(newHex)) return;
341
+ setHexInput(newHex);
342
+ if (!controlled) setInternalHex(newHex);
343
+ onChange?.(formatColor(newHex, newAlpha, format), format);
344
+ }, [alpha, controlled, format, onChange]);
345
+
346
+ // Sync hexInput when value changes externally
347
+ useEffect(() => {
348
+ if (controlled && value && isValidHex(value)) {
349
+ setHexInput(value);
350
+ }
351
+ }, [controlled, value]);
352
+
353
+ // Position popover
354
+ const openPicker = () => {
355
+ if (disabled) return;
356
+ const trigger = triggerRef.current;
357
+ if (!trigger) return;
358
+ const rect = trigger.getBoundingClientRect();
359
+ const spaceBelow = window.innerHeight - rect.bottom;
360
+ const top = spaceBelow > 320 ? rect.bottom + 6 : rect.top - 326;
361
+ setPopPos({ top: top + window.scrollY, left: rect.left + window.scrollX });
362
+ setIsOpen(true);
363
+ };
364
+
365
+ // Close on outside click
366
+ useEffect(() => {
367
+ if (!isOpen) return;
368
+ const handler = (e: MouseEvent) => {
369
+ if (
370
+ popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
371
+ triggerRef.current && !triggerRef.current.contains(e.target as Node)
372
+ ) {
373
+ setIsOpen(false);
374
+ }
375
+ };
376
+ document.addEventListener('mousedown', handler);
377
+ return () => document.removeEventListener('mousedown', handler);
378
+ }, [isOpen]);
379
+
380
+ // Close on Escape
381
+ useEffect(() => {
382
+ if (!isOpen) return;
383
+ const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsOpen(false); };
384
+ document.addEventListener('keydown', handler);
385
+ return () => document.removeEventListener('keydown', handler);
386
+ }, [isOpen]);
387
+
388
+ const handleSatBright = (s: number, b: number) => {
389
+ // Convert SB + hue back to hex
390
+ // Using HSL approximation: L = B * (1 - S/2/100), S_hsl = (B - L) / min(L, 1-L)
391
+ const bNorm = b / 100;
392
+ const sNorm = s / 100;
393
+ const l = bNorm * (1 - sNorm / 2);
394
+ const sHsl = l === 0 || l === 1 ? 0 : (bNorm - l) / Math.min(l, 1 - l);
395
+ const newHex = hslToHex(hue, Math.round(sHsl * 100), Math.round(l * 100));
396
+ updateColor(newHex);
397
+ };
398
+
399
+ const handleHue = (newHue: number) => {
400
+ const newHex = hslToHex(newHue, sat, light);
401
+ updateColor(newHex);
402
+ };
403
+
404
+ const handleAlpha = (a: number) => {
405
+ setAlpha(a);
406
+ onChange?.(formatColor(hex, a, format), format);
407
+ };
408
+
409
+ const handleHexInput = (e: ChangeEvent<HTMLInputElement>) => {
410
+ const v = e.target.value;
411
+ setHexInput(v);
412
+ if (isValidHex(v)) updateColor(v);
413
+ };
414
+
415
+ const handleRgbInput = (channel: 'r' | 'g' | 'b', v: string) => {
416
+ const n = Math.max(0, Math.min(255, parseInt(v) || 0));
417
+ const [r, g, b] = hexToRgb(hex);
418
+ const newHex = channel === 'r' ? rgbToHex(n, g, b)
419
+ : channel === 'g' ? rgbToHex(r, n, b)
420
+ : rgbToHex(r, g, n);
421
+ updateColor(newHex);
422
+ };
423
+
424
+ const sizeMap = { sm: 28, md: 34, lg: 40 };
425
+ const swatchSize = sizeMap[size];
426
+ const [r, g, b] = hexToRgb(isValidHex(hex) ? hex : defaultValue);
427
+
428
+ const triggerStyle: CSSProperties = {
429
+ display: 'inline-flex',
430
+ alignItems: 'center',
431
+ gap: '8px',
432
+ height: `${swatchSize}px`,
433
+ padding: '4px 10px 4px 6px',
434
+ borderRadius: '8px',
435
+ border: `1px solid ${theme.border}`,
436
+ backgroundColor: theme.surface,
437
+ cursor: disabled ? 'not-allowed' : 'pointer',
438
+ opacity: disabled ? 0.5 : 1,
439
+ color: theme.text,
440
+ fontSize: '13px',
441
+ fontFamily: 'inherit',
442
+ transition: 'border-color 0.15s',
443
+ ...style,
444
+ };
445
+
446
+ const swatchStyle: CSSProperties = {
447
+ width: `${swatchSize - 8}px`,
448
+ height: `${swatchSize - 8}px`,
449
+ borderRadius: '4px',
450
+ backgroundColor: `rgba(${r},${g},${b},${alpha})`,
451
+ border: `1px solid ${theme.border}`,
452
+ flexShrink: 0,
453
+ };
454
+
455
+ const popStyle: CSSProperties = {
456
+ position: 'absolute',
457
+ top: popPos.top,
458
+ left: popPos.left,
459
+ zIndex: 9999,
460
+ width: '260px',
461
+ backgroundColor: theme.surface,
462
+ border: `1px solid ${theme.border}`,
463
+ borderRadius: '12px',
464
+ padding: '16px',
465
+ boxShadow: `0 8px 32px rgba(0,0,0,0.24)`,
466
+ display: 'flex',
467
+ flexDirection: 'column',
468
+ gap: '12px',
469
+ };
470
+
471
+ const inputStyle: CSSProperties = {
472
+ width: '100%',
473
+ padding: '6px 10px',
474
+ borderRadius: '6px',
475
+ border: `1px solid ${theme.border}`,
476
+ backgroundColor: theme.bg,
477
+ color: theme.text,
478
+ fontSize: '12px',
479
+ fontFamily: 'monospace',
480
+ outline: 'none',
481
+ };
482
+
483
+ const channelInputStyle: CSSProperties = {
484
+ flex: 1,
485
+ padding: '5px 6px',
486
+ borderRadius: '6px',
487
+ border: `1px solid ${theme.border}`,
488
+ backgroundColor: theme.bg,
489
+ color: theme.text,
490
+ fontSize: '11px',
491
+ textAlign: 'center',
492
+ fontFamily: 'monospace',
493
+ outline: 'none',
494
+ };
495
+
496
+ const presetDotStyle = (c: string): CSSProperties => ({
497
+ width: '20px',
498
+ height: '20px',
499
+ borderRadius: '50%',
500
+ backgroundColor: c,
501
+ border: c.toLowerCase() === hex.toLowerCase()
502
+ ? `2px solid ${theme.primary}`
503
+ : `1px solid ${theme.border}`,
504
+ cursor: 'pointer',
505
+ flexShrink: 0,
506
+ transition: 'transform 0.1s',
507
+ });
508
+
509
+ return (
510
+ <>
511
+ {label && (
512
+ <label style={{ display: 'block', fontSize: '13px', color: theme.text, marginBottom: '6px', fontWeight: 500 }}>
513
+ {label}
514
+ </label>
515
+ )}
516
+ <button
517
+ ref={triggerRef}
518
+ type="button"
519
+ style={triggerStyle}
520
+ onClick={openPicker}
521
+ disabled={disabled}
522
+ aria-label={`Color picker, current color ${hex}`}
523
+ aria-haspopup="dialog"
524
+ aria-expanded={isOpen}
525
+ >
526
+ <div style={swatchStyle} />
527
+ <span style={{ fontFamily: 'monospace', fontSize: '12px', color: theme.textMuted }}>
528
+ {placeholder ?? formatColor(isValidHex(hex) ? hex : defaultValue, alpha, format)}
529
+ </span>
530
+ </button>
531
+
532
+ {isOpen && createPortal(
533
+ <div
534
+ ref={popoverRef}
535
+ style={popStyle}
536
+ role="dialog"
537
+ aria-label="Color picker"
538
+ >
539
+ {/* Saturation/Brightness picker */}
540
+ <SatBrightPicker
541
+ hue={hue}
542
+ sat={sat}
543
+ bright={light}
544
+ onChange={handleSatBright}
545
+ />
546
+
547
+ {/* Hue slider */}
548
+ <HueSlider hue={hue} onChange={handleHue} />
549
+
550
+ {/* Alpha slider */}
551
+ {showAlpha && (
552
+ <AlphaSlider alpha={alpha} hex={isValidHex(hex) ? hex : defaultValue} onChange={handleAlpha} />
553
+ )}
554
+
555
+ {/* Hex input + preview swatch */}
556
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
557
+ <div style={{
558
+ width: '28px', height: '28px', borderRadius: '6px', flexShrink: 0,
559
+ backgroundColor: `rgba(${r},${g},${b},${alpha})`,
560
+ border: `1px solid ${theme.border}`,
561
+ }} />
562
+ <input
563
+ style={inputStyle}
564
+ value={hexInput}
565
+ onChange={handleHexInput}
566
+ maxLength={7}
567
+ spellCheck={false}
568
+ aria-label="Hex color value"
569
+ />
570
+ </div>
571
+
572
+ {/* RGB inputs */}
573
+ <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
574
+ {(['r', 'g', 'b'] as const).map((ch) => {
575
+ const val = ch === 'r' ? r : ch === 'g' ? g : b;
576
+ return (
577
+ <div key={ch} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '3px', alignItems: 'center' }}>
578
+ <input
579
+ style={channelInputStyle}
580
+ type="number"
581
+ min={0}
582
+ max={255}
583
+ value={val}
584
+ onChange={(e) => handleRgbInput(ch, e.target.value)}
585
+ aria-label={`${ch.toUpperCase()} channel`}
586
+ />
587
+ <span style={{ fontSize: '10px', color: theme.textMuted, textTransform: 'uppercase' }}>{ch}</span>
588
+ </div>
589
+ );
590
+ })}
591
+ {showAlpha && (
592
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '3px', alignItems: 'center' }}>
593
+ <input
594
+ style={channelInputStyle}
595
+ type="number"
596
+ min={0}
597
+ max={100}
598
+ value={Math.round(alpha * 100)}
599
+ onChange={(e) => handleAlpha(Math.max(0, Math.min(1, parseInt(e.target.value) / 100 || 0)))}
600
+ aria-label="Alpha channel"
601
+ />
602
+ <span style={{ fontSize: '10px', color: theme.textMuted }}>A%</span>
603
+ </div>
604
+ )}
605
+ </div>
606
+
607
+ {/* Presets */}
608
+ {presets.length > 0 && (
609
+ <div>
610
+ <div style={{ fontSize: '11px', color: theme.textMuted, marginBottom: '6px', fontWeight: 500 }}>
611
+ Presets
612
+ </div>
613
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
614
+ {presets.map((c) => (
615
+ <button
616
+ key={c}
617
+ type="button"
618
+ style={presetDotStyle(c)}
619
+ onClick={() => updateColor(c)}
620
+ aria-label={`Select color ${c}`}
621
+ onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = 'scale(1.15)'; }}
622
+ onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = 'scale(1)'; }}
623
+ />
624
+ ))}
625
+ </div>
626
+ </div>
627
+ )}
628
+ </div>,
629
+ document.body
630
+ )}
631
+ </>
632
+ );
633
+ }