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,277 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useCallback,
5
+ useRef,
6
+ type ReactNode,
7
+ type ImgHTMLAttributes,
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import { useTheme } from '../themes';
11
+ import { sanitizeString } from '../engine/security';
12
+ import { useReducedMotion } from '../hooks';
13
+ import { tkx, cx } from '../engine/tkx';
14
+
15
+ export type ImageFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
16
+ export type ImageRatio = '1/1' | '4/3' | '16/9' | '3/2' | '9/16' | 'auto';
17
+
18
+ export interface TkxImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
19
+ src: string;
20
+ fallback?: ReactNode;
21
+ placeholder?: 'blur' | 'skeleton' | 'none';
22
+ fit?: ImageFit;
23
+ ratio?: ImageRatio;
24
+ radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
25
+ caption?: string;
26
+ lazy?: boolean;
27
+ preview?: boolean;
28
+ }
29
+
30
+ const RATIO_PADDING: Record<Exclude<ImageRatio, 'auto'>, string> = {
31
+ '1/1': '100%',
32
+ '4/3': '75%',
33
+ '16/9': '56.25%',
34
+ '3/2': '66.666%',
35
+ '9/16': '177.777%',
36
+ };
37
+
38
+ const RADIUS_MAP: Record<NonNullable<TkxImageProps['radius']>, string> = {
39
+ none: '0',
40
+ sm: '4px',
41
+ md: '8px',
42
+ lg: '16px',
43
+ full: '9999px',
44
+ };
45
+
46
+ function BrokenImagePlaceholder({ color, size }: { color: string; size: number }) {
47
+ return (
48
+ <svg
49
+ width={size}
50
+ height={size}
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke={color}
54
+ strokeWidth={1.5}
55
+ aria-hidden="true"
56
+ >
57
+ <rect x="3" y="3" width="18" height="18" rx="2" />
58
+ <circle cx="8.5" cy="8.5" r="1.5" />
59
+ <path d="M21 15l-5-5L5 21" />
60
+ </svg>
61
+ );
62
+ }
63
+
64
+ export function TkxImage({
65
+ src,
66
+ alt,
67
+ fallback,
68
+ placeholder = 'skeleton',
69
+ fit = 'cover',
70
+ ratio = 'auto',
71
+ radius = 'none',
72
+ caption,
73
+ lazy = false,
74
+ preview = false,
75
+ className,
76
+ style,
77
+ ...rest
78
+ }: TkxImageProps) {
79
+ const theme = useTheme();
80
+ const reducedMotion = useReducedMotion();
81
+ const [loaded, setLoaded] = useState(false);
82
+ const [errored, setErrored] = useState(false);
83
+ const [lightboxOpen, setLightboxOpen] = useState(false);
84
+ const imgRef = useRef<HTMLImageElement>(null);
85
+
86
+ const safeSrc = sanitizeString(src);
87
+ const safeAlt = alt ? sanitizeString(alt) : '';
88
+ const safeCaption = caption ? sanitizeString(caption) : '';
89
+ const borderRadius = RADIUS_MAP[radius];
90
+
91
+ // Check if image is already cached / complete
92
+ useEffect(() => {
93
+ if (imgRef.current?.complete) {
94
+ setLoaded(true);
95
+ }
96
+ }, []);
97
+
98
+ const handleLoad = useCallback(() => setLoaded(true), []);
99
+ const handleError = useCallback(() => setErrored(true), []);
100
+
101
+ const openLightbox = useCallback(() => {
102
+ if (preview && !errored) setLightboxOpen(true);
103
+ }, [preview, errored]);
104
+
105
+ const closeLightbox = useCallback(() => setLightboxOpen(false), []);
106
+
107
+ useEffect(() => {
108
+ if (!lightboxOpen) return;
109
+ const handler = (e: KeyboardEvent) => {
110
+ if (e.key === 'Escape') closeLightbox();
111
+ };
112
+ document.addEventListener('keydown', handler);
113
+ return () => document.removeEventListener('keydown', handler);
114
+ }, [lightboxOpen, closeLightbox]);
115
+
116
+ const showSkeleton = placeholder === 'skeleton' && !loaded && !errored;
117
+
118
+ const shimmerStyle = showSkeleton && !reducedMotion
119
+ ? { animation: 'tkx-shimmer 1.5s ease-in-out infinite' }
120
+ : {};
121
+
122
+ const imgElement = !errored ? (
123
+ <img
124
+ ref={imgRef}
125
+ src={safeSrc}
126
+ alt={safeAlt || undefined}
127
+ role={safeAlt ? undefined : 'img'}
128
+ aria-label={safeAlt ? undefined : 'Image'}
129
+ loading={lazy ? 'lazy' : undefined}
130
+ onLoad={handleLoad}
131
+ onError={handleError}
132
+ onClick={preview ? openLightbox : undefined}
133
+ className={cx(
134
+ tkx('block w-full h-full'),
135
+ preview && !errored ? tkx('cursor-pointer') : '',
136
+ )}
137
+ style={{
138
+ objectFit: fit,
139
+ borderRadius,
140
+ opacity: loaded ? 1 : 0,
141
+ transition: reducedMotion ? 'none' : 'opacity 200ms ease',
142
+ position: ratio === 'auto' ? undefined : 'absolute',
143
+ inset: ratio === 'auto' ? undefined : 0,
144
+ }}
145
+ {...rest}
146
+ />
147
+ ) : null;
148
+
149
+ const errorElement = errored ? (
150
+ fallback ? (
151
+ <>{fallback}</>
152
+ ) : (
153
+ <div
154
+ role="img"
155
+ aria-label={safeAlt || 'Image failed to load'}
156
+ className={tkx('flex flex-col items-center justify-center gap-2 w-full h-full')}
157
+ style={{
158
+ backgroundColor: theme.surfaceAlt,
159
+ borderRadius,
160
+ color: theme.textMuted,
161
+ minHeight: '80px',
162
+ }}
163
+ >
164
+ <BrokenImagePlaceholder color={theme.textMuted} size={32} />
165
+ <span className={tkx('text-xs')} style={{ color: theme.textMuted }}>
166
+ Failed to load
167
+ </span>
168
+ </div>
169
+ )
170
+ ) : null;
171
+
172
+ const skeletonElement = showSkeleton ? (
173
+ <div
174
+ aria-hidden="true"
175
+ style={{
176
+ position: ratio === 'auto' ? undefined : 'absolute',
177
+ inset: ratio === 'auto' ? undefined : 0,
178
+ width: '100%',
179
+ height: ratio === 'auto' ? '100%' : undefined,
180
+ minHeight: ratio === 'auto' ? '80px' : undefined,
181
+ borderRadius,
182
+ backgroundColor: theme.surfaceAlt,
183
+ ...shimmerStyle,
184
+ }}
185
+ />
186
+ ) : null;
187
+
188
+ const inner =
189
+ ratio === 'auto' ? (
190
+ <div className={tkx('relative w-full')} style={{ borderRadius }}>
191
+ {skeletonElement}
192
+ {imgElement}
193
+ {errorElement}
194
+ </div>
195
+ ) : (
196
+ <div
197
+ className={tkx('relative w-full overflow-hidden')}
198
+ style={{ paddingBottom: RATIO_PADDING[ratio as Exclude<ImageRatio, 'auto'>], borderRadius }}
199
+ >
200
+ {skeletonElement}
201
+ {imgElement}
202
+ {errorElement}
203
+ </div>
204
+ );
205
+
206
+ const content = safeCaption ? (
207
+ <figure className={tkx('m-0 p-0 flex flex-col gap-2')} style={{ borderRadius }}>
208
+ {inner}
209
+ <figcaption
210
+ className={tkx('text-sm text-center')}
211
+ style={{ color: theme.textMuted }}
212
+ >
213
+ {safeCaption}
214
+ </figcaption>
215
+ </figure>
216
+ ) : (
217
+ inner
218
+ );
219
+
220
+ const wrapped = (
221
+ <div className={cx(tkx('inline-block w-full'), className)} style={style}>
222
+ {content}
223
+ </div>
224
+ );
225
+
226
+ const lightbox =
227
+ lightboxOpen && typeof document !== 'undefined'
228
+ ? createPortal(
229
+ <div
230
+ role="dialog"
231
+ aria-modal="true"
232
+ aria-label={safeAlt || 'Image preview'}
233
+ className={tkx('fixed inset-0 z-[9500] flex items-center justify-center p-4')}
234
+ onClick={closeLightbox}
235
+ >
236
+ <div
237
+ aria-hidden="true"
238
+ className={tkx('absolute inset-0')}
239
+ style={{ backgroundColor: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(6px)' }}
240
+ />
241
+ <img
242
+ src={safeSrc}
243
+ alt={safeAlt || 'Full-size preview'}
244
+ onClick={(e) => e.stopPropagation()}
245
+ className={tkx('relative z-10 max-w-full max-h-full')}
246
+ style={{
247
+ objectFit: 'contain',
248
+ borderRadius: '8px',
249
+ boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
250
+ }}
251
+ />
252
+ <button
253
+ aria-label="Close preview"
254
+ onClick={closeLightbox}
255
+ className={tkx(
256
+ 'absolute top-4 right-4 z-10 flex items-center justify-center',
257
+ 'bg-transparent border-none cursor-pointer rounded-full p-2',
258
+ )}
259
+ style={{ color: '#fff', backgroundColor: 'rgba(255,255,255,0.15)' }}
260
+ >
261
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
262
+ <line x1="18" y1="6" x2="6" y2="18" />
263
+ <line x1="6" y1="6" x2="18" y2="18" />
264
+ </svg>
265
+ </button>
266
+ </div>,
267
+ document.body,
268
+ )
269
+ : null;
270
+
271
+ return (
272
+ <>
273
+ {wrapped}
274
+ {lightbox}
275
+ </>
276
+ );
277
+ }
@@ -0,0 +1,101 @@
1
+ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { tkx, cx } from '../engine/tkx';
5
+
6
+ export interface TkxInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'id'> {
7
+ label: string;
8
+ id?: string;
9
+ error?: string;
10
+ hint?: string;
11
+ leftAddon?: ReactNode;
12
+ rightAddon?: ReactNode;
13
+ isInvalid?: boolean;
14
+ isRequired?: boolean;
15
+ }
16
+
17
+ export const TkxInput = forwardRef<HTMLInputElement, TkxInputProps>(
18
+ ({ label, id: idProp, error, hint, leftAddon, rightAddon, isInvalid, isRequired, disabled, className, style, ...rest }, ref) => {
19
+ const theme = useTheme();
20
+ const autoId = useId();
21
+ const id = idProp ?? autoId;
22
+ const hintId = `${id}-hint`;
23
+ const errorId = `${id}-error`;
24
+ const hasError = isInvalid || !!error;
25
+ const describedBy = [hint && hintId, hasError && errorId].filter(Boolean).join(' ') || undefined;
26
+
27
+ const safeLabel = sanitizeString(label);
28
+ const safeError = error ? sanitizeString(error) : undefined;
29
+ const safeHint = hint ? sanitizeString(hint) : undefined;
30
+
31
+ const borderColor = hasError ? theme.danger : theme.border;
32
+
33
+ return (
34
+ <div className={cx(tkx('flex flex-col gap-1 w-full'), className)} style={style}>
35
+ <label
36
+ htmlFor={id}
37
+ className={tkx('text-sm font-medium font-sans')}
38
+ style={{ color: theme.text }}
39
+ >
40
+ {safeLabel}
41
+ {isRequired && <span aria-hidden="true" className={tkx('ml-1')} style={{ color: theme.danger }}>*</span>}
42
+ </label>
43
+
44
+ <div
45
+ className={tkx(
46
+ 'flex items-center rounded-lg overflow-hidden',
47
+ 'transition-colors duration-150',
48
+ disabled ? 'opacity-60' : '',
49
+ )}
50
+ style={{ border: `1.5px solid ${borderColor}`, backgroundColor: theme.surface }}
51
+ >
52
+ {leftAddon && (
53
+ <div
54
+ className={tkx('px-2.5 self-stretch flex items-center text-sm shrink-0')}
55
+ style={{ backgroundColor: theme.surfaceAlt, color: theme.textMuted, borderRight: `1px solid ${theme.border}` }}
56
+ >
57
+ {leftAddon}
58
+ </div>
59
+ )}
60
+
61
+ <input
62
+ ref={ref}
63
+ id={id}
64
+ aria-invalid={hasError}
65
+ aria-required={isRequired}
66
+ aria-describedby={describedBy}
67
+ disabled={disabled}
68
+ className={cx(
69
+ tkx('flex-1 border-none bg-transparent text-sm font-sans py-2.5 px-3 outline-none min-w-0 focus-visible:focus-ring'),
70
+ )}
71
+ style={{ color: theme.text }}
72
+ {...rest}
73
+ />
74
+
75
+ {rightAddon && (
76
+ <div
77
+ className={tkx('px-2.5 self-stretch flex items-center text-sm shrink-0')}
78
+ style={{ backgroundColor: theme.surfaceAlt, color: theme.textMuted, borderLeft: `1px solid ${theme.border}` }}
79
+ >
80
+ {rightAddon}
81
+ </div>
82
+ )}
83
+ </div>
84
+
85
+ {safeHint && !safeError && (
86
+ <span id={hintId} className={tkx('text-xs')} style={{ color: theme.textMuted }}>{safeHint}</span>
87
+ )}
88
+ {safeError && (
89
+ <span id={errorId} role="alert" className={tkx('text-xs flex items-center gap-1')} style={{ color: theme.danger }}>
90
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
91
+ <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
92
+ </svg>
93
+ {safeError}
94
+ </span>
95
+ )}
96
+ </div>
97
+ );
98
+ },
99
+ );
100
+
101
+ TkxInput.displayName = 'TkxInput';
@@ -0,0 +1,297 @@
1
+ import { type ReactNode, type CSSProperties } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { useReducedMotion } from '../hooks';
4
+ import { cx } from '../engine/tkx';
5
+
6
+ // ── Props ──────────────────────────────────────────────────────────────────────
7
+
8
+ export type LogoVariant = 'full' | 'icon-only' | 'text-only';
9
+ export type LogoSize = 'sm' | 'md' | 'lg' | 'xl';
10
+ export type LogoOrientation = 'horizontal' | 'stacked';
11
+
12
+ export interface TkxLogoProps {
13
+ text?: string;
14
+ tagline?: string;
15
+ variant?: LogoVariant;
16
+ size?: LogoSize;
17
+ orientation?: LogoOrientation;
18
+ mark?: ReactNode;
19
+ color?: string;
20
+ className?: string;
21
+ style?: CSSProperties;
22
+ onClick?: () => void;
23
+ }
24
+
25
+ // ── Size config ────────────────────────────────────────────────────────────────
26
+
27
+ const SIZE_CONFIG: Record<LogoSize, {
28
+ markSize: number;
29
+ fontSize: number;
30
+ taglineSize: number;
31
+ gap: number;
32
+ letterSpacing: string;
33
+ }> = {
34
+ sm: { markSize: 24, fontSize: 14, taglineSize: 10, gap: 6, letterSpacing: '-0.01em' },
35
+ md: { markSize: 32, fontSize: 18, taglineSize: 11, gap: 8, letterSpacing: '-0.02em' },
36
+ lg: { markSize: 44, fontSize: 26, taglineSize: 13, gap: 12, letterSpacing: '-0.03em' },
37
+ xl: { markSize: 60, fontSize: 36, taglineSize: 15, gap: 16, letterSpacing: '-0.04em' },
38
+ };
39
+
40
+ // ── Default SVG Mark ───────────────────────────────────────────────────────────
41
+ // Hexagonal quantum mark with a lightning bolt inside.
42
+
43
+ interface DefaultMarkProps {
44
+ size: number;
45
+ primaryColor: string;
46
+ reducedMotion: boolean;
47
+ }
48
+
49
+ function DefaultMark({ size, primaryColor, reducedMotion }: DefaultMarkProps) {
50
+ const id = 'tkx-logo-gradient';
51
+ const glowId = 'tkx-logo-glow';
52
+
53
+ return (
54
+ <svg
55
+ width={size}
56
+ height={size}
57
+ viewBox="0 0 48 48"
58
+ fill="none"
59
+ xmlns="http://www.w3.org/2000/svg"
60
+ aria-hidden="true"
61
+ style={{
62
+ filter: !reducedMotion ? `drop-shadow(0 0 ${size * 0.15}px ${primaryColor}60)` : undefined,
63
+ flexShrink: 0,
64
+ }}
65
+ >
66
+ <defs>
67
+ <linearGradient id={id} x1="0%" y1="0%" x2="100%" y2="100%">
68
+ <stop offset="0%" stopColor={primaryColor} stopOpacity="1" />
69
+ <stop offset="100%" stopColor={primaryColor} stopOpacity="0.6" />
70
+ </linearGradient>
71
+ <filter id={glowId} x="-20%" y="-20%" width="140%" height="140%">
72
+ <feGaussianBlur stdDeviation="1.5" result="blur" />
73
+ <feMerge>
74
+ <feMergeNode in="blur" />
75
+ <feMergeNode in="SourceGraphic" />
76
+ </feMerge>
77
+ </filter>
78
+ </defs>
79
+
80
+ {/* Outer hexagon */}
81
+ <polygon
82
+ points="24,3 42,13.5 42,34.5 24,45 6,34.5 6,13.5"
83
+ stroke={primaryColor}
84
+ strokeWidth="2"
85
+ fill={`${primaryColor}12`}
86
+ strokeLinejoin="round"
87
+ />
88
+
89
+ {/* Inner hexagon (rotated) */}
90
+ <polygon
91
+ points="24,9 37,16.5 37,31.5 24,39 11,31.5 11,16.5"
92
+ stroke={primaryColor}
93
+ strokeWidth="1"
94
+ strokeOpacity="0.3"
95
+ fill="none"
96
+ strokeLinejoin="round"
97
+ />
98
+
99
+ {/* Lightning bolt */}
100
+ <path
101
+ d="M27 10 L18 26 L24 26 L21 38 L30 22 L24 22 Z"
102
+ fill={`url(#${id})`}
103
+ stroke={primaryColor}
104
+ strokeWidth="0.5"
105
+ strokeLinejoin="round"
106
+ filter={`url(#${glowId})`}
107
+ />
108
+
109
+ {/* Corner accents */}
110
+ <circle cx="24" cy="3" r="1.5" fill={primaryColor} opacity="0.7" />
111
+ <circle cx="42" cy="13.5" r="1.5" fill={primaryColor} opacity="0.5" />
112
+ <circle cx="42" cy="34.5" r="1.5" fill={primaryColor} opacity="0.5" />
113
+ <circle cx="24" cy="45" r="1.5" fill={primaryColor} opacity="0.7" />
114
+ <circle cx="6" cy="34.5" r="1.5" fill={primaryColor} opacity="0.5" />
115
+ <circle cx="6" cy="13.5" r="1.5" fill={primaryColor} opacity="0.5" />
116
+ </svg>
117
+ );
118
+ }
119
+
120
+ // ── Glow animation style injection ────────────────────────────────────────────
121
+
122
+ let glowStyleInjected = false;
123
+
124
+ function injectGlowStyle() {
125
+ if (glowStyleInjected || typeof document === 'undefined') return;
126
+ glowStyleInjected = true;
127
+ const style = document.createElement('style');
128
+ style.id = 'tkx-logo-glow-anim';
129
+ style.textContent = `
130
+ @media (prefers-reduced-motion: no-preference) {
131
+ .tkx-logo-glow {
132
+ animation: tkx-logo-pulse 3s ease-in-out infinite;
133
+ }
134
+ @keyframes tkx-logo-pulse {
135
+ 0%, 100% { opacity: 1; }
136
+ 50% { opacity: 0.75; }
137
+ }
138
+ }
139
+ `;
140
+ document.head.appendChild(style);
141
+ }
142
+
143
+ // ── TkxLogo Component ──────────────────────────────────────────────────────────
144
+
145
+ export function TkxLogo({
146
+ text = 'TekiVex',
147
+ tagline,
148
+ variant = 'full',
149
+ size = 'md',
150
+ orientation = 'horizontal',
151
+ mark,
152
+ color,
153
+ className,
154
+ style,
155
+ onClick,
156
+ }: TkxLogoProps) {
157
+ const theme = useTheme();
158
+ const reducedMotion = useReducedMotion();
159
+ const primaryColor = color ?? theme.primary;
160
+ const config = SIZE_CONFIG[size];
161
+
162
+ // Inject glow animation on first render
163
+ injectGlowStyle();
164
+
165
+ const showMark = variant !== 'text-only';
166
+ const showText = variant !== 'icon-only';
167
+
168
+ const isStacked = orientation === 'stacked';
169
+ const isClickable = typeof onClick === 'function';
170
+
171
+ const containerStyle: CSSProperties = {
172
+ display: 'inline-flex',
173
+ flexDirection: isStacked ? 'column' : 'row',
174
+ alignItems: isStacked ? 'center' : 'center',
175
+ gap: config.gap,
176
+ cursor: isClickable ? 'pointer' : 'default',
177
+ userSelect: 'none',
178
+ textDecoration: 'none',
179
+ ...style,
180
+ };
181
+
182
+ const textGroupStyle: CSSProperties = {
183
+ display: 'flex',
184
+ flexDirection: 'column',
185
+ alignItems: isStacked ? 'center' : 'flex-start',
186
+ gap: 2,
187
+ };
188
+
189
+ const brandTextStyle: CSSProperties = {
190
+ fontSize: config.fontSize,
191
+ fontWeight: 800,
192
+ letterSpacing: config.letterSpacing,
193
+ lineHeight: 1,
194
+ color: theme.text,
195
+ fontFamily: 'inherit',
196
+ margin: 0,
197
+ padding: 0,
198
+ };
199
+
200
+ const taglineStyle: CSSProperties = {
201
+ fontSize: config.taglineSize,
202
+ fontWeight: 500,
203
+ color: theme.textMuted,
204
+ letterSpacing: '0.04em',
205
+ lineHeight: 1,
206
+ margin: 0,
207
+ padding: 0,
208
+ };
209
+
210
+ // Brand name: "Teki" in theme text color, "Vex" in primary color
211
+ const brandParts = splitBrand(text, primaryColor, theme.text);
212
+
213
+ const ariaLabel = tagline
214
+ ? `${text} — ${tagline}`
215
+ : text;
216
+
217
+ const handleKeyDown = (e: React.KeyboardEvent) => {
218
+ if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
219
+ e.preventDefault();
220
+ onClick?.();
221
+ }
222
+ };
223
+
224
+ return (
225
+ <div
226
+ className={cx('tkx-logo-glow', className ?? '')}
227
+ style={containerStyle}
228
+ role="img"
229
+ aria-label={ariaLabel}
230
+ onClick={isClickable ? onClick : undefined}
231
+ onKeyDown={isClickable ? handleKeyDown : undefined}
232
+ tabIndex={isClickable ? 0 : undefined}
233
+ >
234
+ {showMark && (
235
+ mark ?? (
236
+ <DefaultMark
237
+ size={config.markSize}
238
+ primaryColor={primaryColor}
239
+ reducedMotion={reducedMotion}
240
+ />
241
+ )
242
+ )}
243
+
244
+ {showText && (
245
+ <div style={textGroupStyle}>
246
+ <span style={brandTextStyle} aria-hidden="true">
247
+ {brandParts}
248
+ </span>
249
+ {tagline && (
250
+ <span style={taglineStyle} aria-hidden="true">
251
+ {tagline}
252
+ </span>
253
+ )}
254
+ </div>
255
+ )}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ TkxLogo.displayName = 'TkxLogo';
261
+
262
+ // ── Helpers ────────────────────────────────────────────────────────────────────
263
+
264
+ /**
265
+ * Splits the brand text so the last 3 characters are colored with the primary color.
266
+ * For "TekiVex" → "Teki" (text color) + "Vex" (primary color).
267
+ * Falls back to splitting at the midpoint for short names.
268
+ */
269
+ function splitBrand(
270
+ text: string,
271
+ primaryColor: string,
272
+ textColor: string,
273
+ ): ReactNode {
274
+ if (text.length <= 3) {
275
+ return <span style={{ color: primaryColor }}>{text}</span>;
276
+ }
277
+
278
+ // Try to find a natural camelCase split point
279
+ const camelMatch = text.match(/^([A-Za-z]+?)([A-Z][a-z]*)$/);
280
+ if (camelMatch) {
281
+ return (
282
+ <>
283
+ <span style={{ color: textColor }}>{camelMatch[1]}</span>
284
+ <span style={{ color: primaryColor }}>{camelMatch[2]}</span>
285
+ </>
286
+ );
287
+ }
288
+
289
+ // Default: last 3 chars get primary color
290
+ const split = text.length - 3;
291
+ return (
292
+ <>
293
+ <span style={{ color: textColor }}>{text.slice(0, split)}</span>
294
+ <span style={{ color: primaryColor }}>{text.slice(split)}</span>
295
+ </>
296
+ );
297
+ }