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.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/index.cjs +70 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11002 -0
- package/dist/src/a11y/index.d.ts +24 -0
- package/dist/src/a11y/index.d.ts.map +1 -0
- package/dist/src/components/TkxAccordion.d.ts +32 -0
- package/dist/src/components/TkxAccordion.d.ts.map +1 -0
- package/dist/src/components/TkxAlert.d.ts +14 -0
- package/dist/src/components/TkxAlert.d.ts.map +1 -0
- package/dist/src/components/TkxAvatar.d.ts +14 -0
- package/dist/src/components/TkxAvatar.d.ts.map +1 -0
- package/dist/src/components/TkxBadge.d.ts +12 -0
- package/dist/src/components/TkxBadge.d.ts.map +1 -0
- package/dist/src/components/TkxButton.d.ts +17 -0
- package/dist/src/components/TkxButton.d.ts.map +1 -0
- package/dist/src/components/TkxCard.d.ts +20 -0
- package/dist/src/components/TkxCard.d.ts.map +1 -0
- package/dist/src/components/TkxCarousel.d.ts +32 -0
- package/dist/src/components/TkxCarousel.d.ts.map +1 -0
- package/dist/src/components/TkxChat.d.ts +32 -0
- package/dist/src/components/TkxChat.d.ts.map +1 -0
- package/dist/src/components/TkxCheckbox.d.ts +13 -0
- package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
- package/dist/src/components/TkxClock.d.ts +23 -0
- package/dist/src/components/TkxClock.d.ts.map +1 -0
- package/dist/src/components/TkxColorPicker.d.ts +18 -0
- package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
- package/dist/src/components/TkxCommand.d.ts +34 -0
- package/dist/src/components/TkxCommand.d.ts.map +1 -0
- package/dist/src/components/TkxDatePicker.d.ts +47 -0
- package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
- package/dist/src/components/TkxDivider.d.ts +8 -0
- package/dist/src/components/TkxDivider.d.ts.map +1 -0
- package/dist/src/components/TkxDrawer.d.ts +16 -0
- package/dist/src/components/TkxDrawer.d.ts.map +1 -0
- package/dist/src/components/TkxFileUpload.d.ts +16 -0
- package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
- package/dist/src/components/TkxIcon.d.ts +16 -0
- package/dist/src/components/TkxIcon.d.ts.map +1 -0
- package/dist/src/components/TkxImage.d.ts +16 -0
- package/dist/src/components/TkxImage.d.ts.map +1 -0
- package/dist/src/components/TkxInput.d.ts +13 -0
- package/dist/src/components/TkxInput.d.ts.map +1 -0
- package/dist/src/components/TkxLogo.d.ts +21 -0
- package/dist/src/components/TkxLogo.d.ts.map +1 -0
- package/dist/src/components/TkxMenu.d.ts +64 -0
- package/dist/src/components/TkxMenu.d.ts.map +1 -0
- package/dist/src/components/TkxModal.d.ts +14 -0
- package/dist/src/components/TkxModal.d.ts.map +1 -0
- package/dist/src/components/TkxNumberInput.d.ts +32 -0
- package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
- package/dist/src/components/TkxOTP.d.ts +24 -0
- package/dist/src/components/TkxOTP.d.ts.map +1 -0
- package/dist/src/components/TkxPagination.d.ts +19 -0
- package/dist/src/components/TkxPagination.d.ts.map +1 -0
- package/dist/src/components/TkxProgress.d.ts +13 -0
- package/dist/src/components/TkxProgress.d.ts.map +1 -0
- package/dist/src/components/TkxRadio.d.ts +22 -0
- package/dist/src/components/TkxRadio.d.ts.map +1 -0
- package/dist/src/components/TkxRating.d.ts +18 -0
- package/dist/src/components/TkxRating.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts +37 -0
- package/dist/src/components/TkxSelect.d.ts.map +1 -0
- package/dist/src/components/TkxSkeleton.d.ts +12 -0
- package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
- package/dist/src/components/TkxSlider.d.ts +33 -0
- package/dist/src/components/TkxSlider.d.ts.map +1 -0
- package/dist/src/components/TkxStepper.d.ts +31 -0
- package/dist/src/components/TkxStepper.d.ts.map +1 -0
- package/dist/src/components/TkxTable.d.ts +23 -0
- package/dist/src/components/TkxTable.d.ts.map +1 -0
- package/dist/src/components/TkxTabs.d.ts +32 -0
- package/dist/src/components/TkxTabs.d.ts.map +1 -0
- package/dist/src/components/TkxTag.d.ts +30 -0
- package/dist/src/components/TkxTag.d.ts.map +1 -0
- package/dist/src/components/TkxTimeline.d.ts +20 -0
- package/dist/src/components/TkxTimeline.d.ts.map +1 -0
- package/dist/src/components/TkxToast.d.ts +23 -0
- package/dist/src/components/TkxToast.d.ts.map +1 -0
- package/dist/src/components/TkxToggle.d.ts +11 -0
- package/dist/src/components/TkxToggle.d.ts.map +1 -0
- package/dist/src/components/TkxTooltip.d.ts +10 -0
- package/dist/src/components/TkxTooltip.d.ts.map +1 -0
- package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
- package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +41 -0
- package/dist/src/components/index.d.ts.map +1 -0
- package/dist/src/engine/css.d.ts +24 -0
- package/dist/src/engine/css.d.ts.map +1 -0
- package/dist/src/engine/index.d.ts +10 -0
- package/dist/src/engine/index.d.ts.map +1 -0
- package/dist/src/engine/quantum.d.ts +47 -0
- package/dist/src/engine/quantum.d.ts.map +1 -0
- package/dist/src/engine/security.d.ts +54 -0
- package/dist/src/engine/security.d.ts.map +1 -0
- package/dist/src/engine/tkx.d.ts +29 -0
- package/dist/src/engine/tkx.d.ts.map +1 -0
- package/dist/src/engine/wcag.d.ts +34 -0
- package/dist/src/engine/wcag.d.ts.map +1 -0
- package/dist/src/hooks/index.d.ts +8 -0
- package/dist/src/hooks/index.d.ts.map +1 -0
- package/dist/src/themes/index.d.ts +26 -0
- package/dist/src/themes/index.d.ts.map +1 -0
- package/dist/style.css +1 -0
- package/dist/tests/TkxButton.test.d.ts +2 -0
- package/dist/tests/TkxButton.test.d.ts.map +1 -0
- package/dist/tests/a11y/components.test.d.ts +2 -0
- package/dist/tests/a11y/components.test.d.ts.map +1 -0
- package/dist/tests/quantum.test.d.ts +2 -0
- package/dist/tests/quantum.test.d.ts.map +1 -0
- package/dist/tests/security.test.d.ts +2 -0
- package/dist/tests/security.test.d.ts.map +1 -0
- package/dist/tests/setup.d.ts +2 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/tkx.test.d.ts +2 -0
- package/dist/tests/tkx.test.d.ts.map +1 -0
- package/dist/tests/wcag.test.d.ts +2 -0
- package/dist/tests/wcag.test.d.ts.map +1 -0
- package/package.json +78 -0
- package/src/a11y/index.tsx +87 -0
- package/src/components/TkxAccordion.tsx +588 -0
- package/src/components/TkxAlert.tsx +91 -0
- package/src/components/TkxAvatar.tsx +101 -0
- package/src/components/TkxBadge.tsx +73 -0
- package/src/components/TkxButton.tsx +153 -0
- package/src/components/TkxCard.tsx +92 -0
- package/src/components/TkxCarousel.tsx +552 -0
- package/src/components/TkxChat.tsx +354 -0
- package/src/components/TkxCheckbox.tsx +218 -0
- package/src/components/TkxClock.tsx +601 -0
- package/src/components/TkxColorPicker.tsx +633 -0
- package/src/components/TkxCommand.tsx +644 -0
- package/src/components/TkxDatePicker.tsx +1567 -0
- package/src/components/TkxDivider.tsx +49 -0
- package/src/components/TkxDrawer.tsx +234 -0
- package/src/components/TkxFileUpload.tsx +371 -0
- package/src/components/TkxIcon.tsx +683 -0
- package/src/components/TkxImage.tsx +277 -0
- package/src/components/TkxInput.tsx +101 -0
- package/src/components/TkxLogo.tsx +297 -0
- package/src/components/TkxMenu.tsx +1042 -0
- package/src/components/TkxModal.tsx +119 -0
- package/src/components/TkxNumberInput.tsx +408 -0
- package/src/components/TkxOTP.tsx +295 -0
- package/src/components/TkxPagination.tsx +388 -0
- package/src/components/TkxProgress.tsx +114 -0
- package/src/components/TkxRadio.tsx +284 -0
- package/src/components/TkxRating.tsx +286 -0
- package/src/components/TkxSelect.tsx +923 -0
- package/src/components/TkxSkeleton.tsx +105 -0
- package/src/components/TkxSlider.tsx +492 -0
- package/src/components/TkxStepper.tsx +485 -0
- package/src/components/TkxTable.tsx +148 -0
- package/src/components/TkxTabs.tsx +124 -0
- package/src/components/TkxTag.tsx +319 -0
- package/src/components/TkxTimeline.tsx +303 -0
- package/src/components/TkxToast.tsx +284 -0
- package/src/components/TkxToggle.tsx +93 -0
- package/src/components/TkxTooltip.tsx +69 -0
- package/src/components/TkxVideoPlayer.tsx +1013 -0
- package/src/components/index.ts +40 -0
- package/src/engine/css.ts +126 -0
- package/src/engine/index.ts +10 -0
- package/src/engine/quantum.ts +207 -0
- package/src/engine/security.ts +217 -0
- package/src/engine/tkx.ts +874 -0
- package/src/engine/wcag.ts +275 -0
- package/src/hooks/index.ts +120 -0
- package/src/styles/global.css +167 -0
- 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
|
+
}
|