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,601 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useRef,
5
+ type CSSProperties,
6
+ } from 'react';
7
+ import { useTheme } from '../themes';
8
+ import { sanitizeString } from '../engine/security';
9
+ import { useReducedMotion } from '../hooks';
10
+
11
+ // ── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ export type ClockVariant = 'analog' | 'digital' | 'both';
14
+ export type DigitalFormat = '12h' | '24h';
15
+ export type AnalogStyle = 'classic' | 'minimal' | 'modern';
16
+
17
+ export interface TkxClockProps {
18
+ variant?: ClockVariant;
19
+ size?: 'sm' | 'md' | 'lg' | 'xl';
20
+ showSeconds?: boolean;
21
+ showDate?: boolean;
22
+ format?: DigitalFormat;
23
+ timezone?: string;
24
+ timezone2?: string;
25
+ label?: string;
26
+ theme?: 'auto' | 'light' | 'dark';
27
+ analogStyle?: AnalogStyle;
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ }
31
+
32
+ // ── Size map ─────────────────────────────────────────────────────────────────
33
+
34
+ const SIZE_PX = { sm: 120, md: 180, lg: 240, xl: 320 } as const;
35
+
36
+ // ── Time helpers ─────────────────────────────────────────────────────────────
37
+
38
+ interface ClockTime {
39
+ hours: number;
40
+ minutes: number;
41
+ seconds: number;
42
+ milliseconds: number;
43
+ dateString: string;
44
+ }
45
+
46
+ function getTimeInZone(timezone?: string): ClockTime {
47
+ const now = new Date();
48
+
49
+ if (timezone) {
50
+ try {
51
+ const parts = new Intl.DateTimeFormat('en-US', {
52
+ timeZone: timezone,
53
+ hour: 'numeric',
54
+ minute: 'numeric',
55
+ second: 'numeric',
56
+ hour12: false,
57
+ weekday: 'long',
58
+ month: 'long',
59
+ day: '2-digit',
60
+ year: 'numeric',
61
+ }).formatToParts(now);
62
+
63
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
64
+
65
+ let hours = parseInt(get('hour'), 10);
66
+ if (hours === 24) hours = 0;
67
+ const minutes = parseInt(get('minute'), 10);
68
+ const seconds = parseInt(get('second'), 10);
69
+ const weekday = get('weekday');
70
+ const month = get('month');
71
+ const day = get('day');
72
+ const year = get('year');
73
+ const dateString = `${weekday}, ${month} ${day}, ${year}`;
74
+
75
+ return { hours, minutes, seconds, milliseconds: now.getMilliseconds(), dateString };
76
+ } catch {
77
+ // fall through to local
78
+ }
79
+ }
80
+
81
+ const hours = now.getHours();
82
+ const minutes = now.getMinutes();
83
+ const seconds = now.getSeconds();
84
+ const dateString = now.toLocaleDateString('en-US', {
85
+ weekday: 'long',
86
+ month: 'long',
87
+ day: '2-digit',
88
+ year: 'numeric',
89
+ });
90
+
91
+ return { hours, minutes, seconds, milliseconds: now.getMilliseconds(), dateString };
92
+ }
93
+
94
+ // ── Analog face ───────────────────────────────────────────────────────────────
95
+
96
+ interface AnalogFaceProps {
97
+ time: ClockTime;
98
+ size: number;
99
+ analogStyle: AnalogStyle;
100
+ showSeconds: boolean;
101
+ reducedMotion: boolean;
102
+ primary: string;
103
+ danger: string;
104
+ text: string;
105
+ textMuted: string;
106
+ border: string;
107
+ surface: string;
108
+ bg: string;
109
+ }
110
+
111
+ function AnalogFace({
112
+ time,
113
+ size,
114
+ analogStyle,
115
+ showSeconds,
116
+ reducedMotion,
117
+ primary,
118
+ danger,
119
+ text,
120
+ textMuted,
121
+ border,
122
+ surface,
123
+ bg,
124
+ }: AnalogFaceProps) {
125
+ const cx = size / 2;
126
+ const cy = size / 2;
127
+ const r = size / 2 - 4;
128
+
129
+ const { hours, minutes, seconds, milliseconds } = time;
130
+
131
+ const smoothMs = reducedMotion ? 0 : milliseconds;
132
+
133
+ const secondAngle = ((seconds + smoothMs / 1000) / 60) * 360;
134
+ const minuteAngle = (minutes / 60) * 360 + (seconds / 60) * 6;
135
+ const hourAngle = ((hours % 12) / 12) * 360 + (minutes / 60) * 30;
136
+
137
+ const handPoint = (angle: number, length: number) => {
138
+ const rad = ((angle - 90) * Math.PI) / 180;
139
+ return {
140
+ x: cx + Math.cos(rad) * length,
141
+ y: cy + Math.sin(rad) * length,
142
+ };
143
+ };
144
+
145
+ const transition = reducedMotion ? 'none' : 'transform 0.15s cubic-bezier(0.4, 2.08, 0.55, 0.44)';
146
+
147
+ if (analogStyle === 'modern') {
148
+ // Arc-based progress indicators
149
+ const describeArc = (percent: number, radius: number) => {
150
+ const startAngle = -90;
151
+ const endAngle = startAngle + percent * 360;
152
+ const start = {
153
+ x: cx + Math.cos((startAngle * Math.PI) / 180) * radius,
154
+ y: cy + Math.sin((startAngle * Math.PI) / 180) * radius,
155
+ };
156
+ const end = {
157
+ x: cx + Math.cos((endAngle * Math.PI) / 180) * radius,
158
+ y: cy + Math.sin((endAngle * Math.PI) / 180) * radius,
159
+ };
160
+ const largeArc = percent > 0.5 ? 1 : 0;
161
+ return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
162
+ };
163
+
164
+ const hourPct = ((hours % 12) / 12) + (minutes / 60) / 12;
165
+ const minPct = minutes / 60 + seconds / 3600;
166
+ const secPct = (seconds + (reducedMotion ? 0 : milliseconds / 1000)) / 60;
167
+
168
+ const outerR = r - 2;
169
+ const midR = r - 10;
170
+ const innerR = r - 18;
171
+
172
+ return (
173
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
174
+ {/* Background rings */}
175
+ <circle cx={cx} cy={cy} r={outerR} fill="none" stroke={border} strokeWidth={4} />
176
+ <circle cx={cx} cy={cy} r={midR} fill="none" stroke={border} strokeWidth={4} />
177
+ {showSeconds && <circle cx={cx} cy={cy} r={innerR} fill="none" stroke={border} strokeWidth={3} />}
178
+
179
+ {/* Hour arc */}
180
+ <path d={describeArc(hourPct, outerR)} fill="none" stroke={primary} strokeWidth={4} strokeLinecap="round" />
181
+ {/* Minute arc */}
182
+ <path d={describeArc(minPct, midR)} fill="none" stroke={text} strokeWidth={4} strokeLinecap="round" />
183
+ {/* Second arc */}
184
+ {showSeconds && (
185
+ <path d={describeArc(secPct, innerR)} fill="none" stroke={danger} strokeWidth={3} strokeLinecap="round" />
186
+ )}
187
+
188
+ {/* Center dot */}
189
+ <circle cx={cx} cy={cy} r={4} fill={primary} />
190
+
191
+ {/* Time digits in center */}
192
+ <text
193
+ x={cx}
194
+ y={cy + 20}
195
+ textAnchor="middle"
196
+ fontSize={size * 0.12}
197
+ fill={textMuted}
198
+ fontFamily="monospace"
199
+ style={{ fontVariantNumeric: 'tabular-nums' }}
200
+ >
201
+ {String(hours).padStart(2, '0')}:{String(minutes).padStart(2, '0')}
202
+ </text>
203
+ </svg>
204
+ );
205
+ }
206
+
207
+ // Classic and Minimal styles use hands
208
+ const hourEnd = handPoint(hourAngle, r * 0.5);
209
+ const minuteEnd = handPoint(minuteAngle, r * 0.75);
210
+ const secondEnd = handPoint(secondAngle, r * 0.85);
211
+ const secondTailEnd = handPoint(secondAngle + 180, r * 0.2);
212
+
213
+ const tickMarks: JSX.Element[] = [];
214
+
215
+ if (analogStyle === 'classic') {
216
+ // 12 hour ticks + minor ticks
217
+ for (let i = 0; i < 60; i++) {
218
+ const angle = (i / 60) * 360;
219
+ const isHour = i % 5 === 0;
220
+ const rad = ((angle - 90) * Math.PI) / 180;
221
+ const innerR = isHour ? r - 10 : r - 6;
222
+ const x1 = cx + Math.cos(rad) * r;
223
+ const y1 = cy + Math.sin(rad) * r;
224
+ const x2 = cx + Math.cos(rad) * innerR;
225
+ const y2 = cy + Math.sin(rad) * innerR;
226
+ tickMarks.push(
227
+ <line
228
+ key={i}
229
+ x1={x1}
230
+ y1={y1}
231
+ x2={x2}
232
+ y2={y2}
233
+ stroke={isHour ? text : textMuted}
234
+ strokeWidth={isHour ? 2 : 1}
235
+ strokeLinecap="round"
236
+ />,
237
+ );
238
+ }
239
+
240
+ // Numbers 12, 3, 6, 9
241
+ const numberPositions = [
242
+ { n: 12, angle: 0 },
243
+ { n: 3, angle: 90 },
244
+ { n: 6, angle: 180 },
245
+ { n: 9, angle: 270 },
246
+ ];
247
+ for (const { n, angle } of numberPositions) {
248
+ const rad = ((angle - 90) * Math.PI) / 180;
249
+ const nr = r - 20;
250
+ tickMarks.push(
251
+ <text
252
+ key={`n${n}`}
253
+ x={cx + Math.cos(rad) * nr}
254
+ y={cy + Math.sin(rad) * nr}
255
+ textAnchor="middle"
256
+ dominantBaseline="central"
257
+ fontSize={size * 0.09}
258
+ fill={text}
259
+ fontFamily="system-ui, sans-serif"
260
+ fontWeight="600"
261
+ >
262
+ {n}
263
+ </text>,
264
+ );
265
+ }
266
+ } else {
267
+ // Minimal: just dots at hour positions
268
+ for (let i = 0; i < 12; i++) {
269
+ const angle = (i / 12) * 360;
270
+ const rad = ((angle - 90) * Math.PI) / 180;
271
+ const dr = r - 8;
272
+ tickMarks.push(
273
+ <circle
274
+ key={i}
275
+ cx={cx + Math.cos(rad) * dr}
276
+ cy={cy + Math.sin(rad) * dr}
277
+ r={i % 3 === 0 ? 3 : 2}
278
+ fill={i % 3 === 0 ? text : textMuted}
279
+ />,
280
+ );
281
+ }
282
+ }
283
+
284
+ return (
285
+ <svg
286
+ width={size}
287
+ height={size}
288
+ viewBox={`0 0 ${size} ${size}`}
289
+ aria-hidden="true"
290
+ style={{ display: 'block' }}
291
+ >
292
+ {/* Face background */}
293
+ <circle cx={cx} cy={cy} r={r} fill={surface} stroke={border} strokeWidth={2} />
294
+
295
+ {/* Tick marks / numbers */}
296
+ {tickMarks}
297
+
298
+ {/* Hour hand */}
299
+ <line
300
+ x1={cx}
301
+ y1={cy}
302
+ x2={hourEnd.x}
303
+ y2={hourEnd.y}
304
+ stroke={primary}
305
+ strokeWidth={analogStyle === 'minimal' ? 3 : 4}
306
+ strokeLinecap="round"
307
+ style={{
308
+ transformOrigin: `${cx}px ${cy}px`,
309
+ transition,
310
+ }}
311
+ />
312
+
313
+ {/* Minute hand */}
314
+ <line
315
+ x1={cx}
316
+ y1={cy}
317
+ x2={minuteEnd.x}
318
+ y2={minuteEnd.y}
319
+ stroke={text}
320
+ strokeWidth={analogStyle === 'minimal' ? 2 : 3}
321
+ strokeLinecap="round"
322
+ style={{
323
+ transformOrigin: `${cx}px ${cy}px`,
324
+ transition,
325
+ }}
326
+ />
327
+
328
+ {/* Second hand */}
329
+ {showSeconds && (
330
+ <>
331
+ <line
332
+ x1={secondTailEnd.x}
333
+ y1={secondTailEnd.y}
334
+ x2={secondEnd.x}
335
+ y2={secondEnd.y}
336
+ stroke={danger}
337
+ strokeWidth={1.5}
338
+ strokeLinecap="round"
339
+ style={{
340
+ transformOrigin: `${cx}px ${cy}px`,
341
+ transition: reducedMotion ? 'none' : 'none', // second hand: no CSS transition, update via state
342
+ }}
343
+ />
344
+ <circle cx={cx} cy={cy} r={3} fill={danger} />
345
+ </>
346
+ )}
347
+
348
+ {/* Center cap */}
349
+ <circle cx={cx} cy={cy} r={analogStyle === 'minimal' ? 3 : 5} fill={primary} />
350
+ </svg>
351
+ );
352
+ }
353
+
354
+ // ── Digital display ───────────────────────────────────────────────────────────
355
+
356
+ interface DigitalDisplayProps {
357
+ time: ClockTime;
358
+ format: DigitalFormat;
359
+ showSeconds: boolean;
360
+ showDate: boolean;
361
+ label?: string;
362
+ size: number;
363
+ primary: string;
364
+ text: string;
365
+ textMuted: string;
366
+ colonVisible: boolean;
367
+ }
368
+
369
+ function DigitalDisplay({
370
+ time,
371
+ format,
372
+ showSeconds,
373
+ showDate,
374
+ label,
375
+ size,
376
+ primary,
377
+ text,
378
+ textMuted,
379
+ colonVisible,
380
+ }: DigitalDisplayProps) {
381
+ const { hours, minutes, seconds, dateString } = time;
382
+
383
+ let displayHours = hours;
384
+ let ampm = '';
385
+
386
+ if (format === '12h') {
387
+ ampm = hours >= 12 ? 'PM' : 'AM';
388
+ displayHours = hours % 12 || 12;
389
+ }
390
+
391
+ const fontSize = size * 0.2;
392
+ const colonStyle: CSSProperties = {
393
+ opacity: colonVisible ? 1 : 0,
394
+ transition: 'opacity 0.1s',
395
+ display: 'inline-block',
396
+ minWidth: '0.5ch',
397
+ textAlign: 'center',
398
+ };
399
+
400
+ return (
401
+ <div
402
+ style={{
403
+ textAlign: 'center',
404
+ fontFamily: "'Courier New', Courier, monospace",
405
+ fontVariantNumeric: 'tabular-nums',
406
+ lineHeight: 1.1,
407
+ }}
408
+ >
409
+ <div
410
+ style={{
411
+ fontSize,
412
+ color: primary,
413
+ fontWeight: 700,
414
+ letterSpacing: '0.05em',
415
+ display: 'flex',
416
+ alignItems: 'center',
417
+ justifyContent: 'center',
418
+ gap: 0,
419
+ }}
420
+ >
421
+ <span>{String(displayHours).padStart(2, '0')}</span>
422
+ <span style={colonStyle}>:</span>
423
+ <span>{String(minutes).padStart(2, '0')}</span>
424
+ {showSeconds && (
425
+ <>
426
+ <span style={colonStyle}>:</span>
427
+ <span>{String(seconds).padStart(2, '0')}</span>
428
+ </>
429
+ )}
430
+ {format === '12h' && (
431
+ <span
432
+ style={{
433
+ fontSize: fontSize * 0.45,
434
+ color: textMuted,
435
+ marginLeft: '0.4ch',
436
+ alignSelf: 'flex-end',
437
+ marginBottom: fontSize * 0.05,
438
+ }}
439
+ >
440
+ {ampm}
441
+ </span>
442
+ )}
443
+ </div>
444
+
445
+ {showDate && (
446
+ <div
447
+ style={{
448
+ fontSize: size * 0.072,
449
+ color: textMuted,
450
+ marginTop: size * 0.025,
451
+ letterSpacing: '0.03em',
452
+ }}
453
+ >
454
+ {dateString}
455
+ </div>
456
+ )}
457
+
458
+ {label && (
459
+ <div
460
+ style={{
461
+ fontSize: size * 0.065,
462
+ color: textMuted,
463
+ marginTop: size * 0.015,
464
+ textTransform: 'uppercase',
465
+ letterSpacing: '0.08em',
466
+ }}
467
+ >
468
+ {sanitizeString(label)}
469
+ </div>
470
+ )}
471
+ </div>
472
+ );
473
+ }
474
+
475
+ // ── Main component ────────────────────────────────────────────────────────────
476
+
477
+ export function TkxClock({
478
+ variant = 'both',
479
+ size = 'md',
480
+ showSeconds = true,
481
+ showDate = false,
482
+ format = '24h',
483
+ timezone,
484
+ timezone2,
485
+ label,
486
+ analogStyle = 'classic',
487
+ className,
488
+ style,
489
+ }: TkxClockProps) {
490
+ const theme = useTheme();
491
+ const reducedMotion = useReducedMotion();
492
+
493
+ const [time, setTime] = useState<ClockTime>(() => getTimeInZone(timezone));
494
+ const [time2, setTime2] = useState<ClockTime | null>(() =>
495
+ timezone2 ? getTimeInZone(timezone2) : null,
496
+ );
497
+ const [colonVisible, setColonVisible] = useState(true);
498
+ const [mounted, setMounted] = useState(false);
499
+
500
+ const colonRef = useRef<ReturnType<typeof setInterval> | null>(null);
501
+
502
+ useEffect(() => {
503
+ setMounted(true);
504
+ }, []);
505
+
506
+ useEffect(() => {
507
+ const tick = () => {
508
+ setTime(getTimeInZone(timezone));
509
+ if (timezone2) setTime2(getTimeInZone(timezone2));
510
+ };
511
+ tick();
512
+ const id = setInterval(tick, 1000);
513
+ return () => clearInterval(id);
514
+ }, [timezone, timezone2]);
515
+
516
+ useEffect(() => {
517
+ colonRef.current = setInterval(() => {
518
+ setColonVisible((v) => !v);
519
+ }, 500);
520
+ return () => {
521
+ if (colonRef.current) clearInterval(colonRef.current);
522
+ };
523
+ }, []);
524
+
525
+ const sizePx = SIZE_PX[size];
526
+ const isAnalog = variant === 'analog' || variant === 'both';
527
+ const isDigital = variant === 'digital' || variant === 'both';
528
+
529
+ const containerStyle: CSSProperties = {
530
+ display: 'inline-flex',
531
+ flexDirection: 'column',
532
+ alignItems: 'center',
533
+ gap: variant === 'both' ? `${sizePx * 0.06}px` : 0,
534
+ opacity: mounted ? 1 : 0,
535
+ transition: reducedMotion ? 'none' : 'opacity 0.4s ease',
536
+ ...style,
537
+ };
538
+
539
+ return (
540
+ <div className={className} style={containerStyle} role="timer" aria-live="off">
541
+ {isAnalog && (
542
+ <AnalogFace
543
+ time={time}
544
+ size={sizePx}
545
+ analogStyle={analogStyle}
546
+ showSeconds={showSeconds}
547
+ reducedMotion={reducedMotion}
548
+ primary={theme.primary}
549
+ danger={theme.danger}
550
+ text={theme.text}
551
+ textMuted={theme.textMuted}
552
+ border={theme.border}
553
+ surface={theme.surface}
554
+ bg={theme.bg}
555
+ />
556
+ )}
557
+
558
+ {isDigital && (
559
+ <DigitalDisplay
560
+ time={time}
561
+ format={format}
562
+ showSeconds={showSeconds}
563
+ showDate={showDate}
564
+ label={label}
565
+ size={sizePx}
566
+ primary={theme.primary}
567
+ text={theme.text}
568
+ textMuted={theme.textMuted}
569
+ colonVisible={colonVisible}
570
+ />
571
+ )}
572
+
573
+ {/* Dual timezone display */}
574
+ {time2 && timezone2 && (
575
+ <div
576
+ style={{
577
+ marginTop: sizePx * 0.04,
578
+ padding: `${sizePx * 0.03}px ${sizePx * 0.06}px`,
579
+ borderTop: `1px solid ${theme.border}`,
580
+ textAlign: 'center',
581
+ }}
582
+ >
583
+ <DigitalDisplay
584
+ time={time2}
585
+ format={format}
586
+ showSeconds={false}
587
+ showDate={false}
588
+ label={timezone2}
589
+ size={sizePx * 0.7}
590
+ primary={theme.secondary}
591
+ text={theme.text}
592
+ textMuted={theme.textMuted}
593
+ colonVisible={colonVisible}
594
+ />
595
+ </div>
596
+ )}
597
+ </div>
598
+ );
599
+ }
600
+
601
+ TkxClock.displayName = 'TkxClock';