tekivex-ui 2.2.0 → 2.3.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 (47) hide show
  1. package/dist/index.cjs +75 -14
  2. package/dist/index.d.ts +32 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +10957 -4936
  5. package/dist/src/components/TkxAffix.d.ts +10 -0
  6. package/dist/src/components/TkxAffix.d.ts.map +1 -0
  7. package/dist/src/components/TkxAnchor.d.ts +14 -0
  8. package/dist/src/components/TkxAnchor.d.ts.map +1 -0
  9. package/dist/src/components/TkxCascader.d.ts +16 -0
  10. package/dist/src/components/TkxCascader.d.ts.map +1 -0
  11. package/dist/src/components/TkxDataGrid.d.ts +5 -1
  12. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  13. package/dist/src/components/TkxList.d.ts +24 -0
  14. package/dist/src/components/TkxList.d.ts.map +1 -0
  15. package/dist/src/components/TkxMentions.d.ts +15 -0
  16. package/dist/src/components/TkxMentions.d.ts.map +1 -0
  17. package/dist/src/components/TkxQRCode.d.ts +11 -0
  18. package/dist/src/components/TkxQRCode.d.ts.map +1 -0
  19. package/dist/src/components/TkxResult.d.ts +11 -0
  20. package/dist/src/components/TkxResult.d.ts.map +1 -0
  21. package/dist/src/components/TkxSegmented.d.ts +16 -0
  22. package/dist/src/components/TkxSegmented.d.ts.map +1 -0
  23. package/dist/src/components/TkxSelect.d.ts +5 -1
  24. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  25. package/dist/src/components/TkxTour.d.ts +15 -0
  26. package/dist/src/components/TkxTour.d.ts.map +1 -0
  27. package/dist/src/components/TkxWatermark.d.ts +12 -0
  28. package/dist/src/components/TkxWatermark.d.ts.map +1 -0
  29. package/dist/src/components/index.d.ts +10 -0
  30. package/dist/src/components/index.d.ts.map +1 -1
  31. package/dist/src/engine/tkx.d.ts +38 -0
  32. package/dist/src/engine/tkx.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/TkxAffix.tsx +138 -0
  35. package/src/components/TkxAnchor.tsx +193 -0
  36. package/src/components/TkxCascader.tsx +276 -0
  37. package/src/components/TkxDataGrid.tsx +65 -2
  38. package/src/components/TkxList.tsx +242 -0
  39. package/src/components/TkxMentions.tsx +210 -0
  40. package/src/components/TkxQRCode.tsx +174 -0
  41. package/src/components/TkxResult.tsx +128 -0
  42. package/src/components/TkxSegmented.tsx +169 -0
  43. package/src/components/TkxSelect.tsx +209 -6
  44. package/src/components/TkxTour.tsx +239 -0
  45. package/src/components/TkxWatermark.tsx +143 -0
  46. package/src/components/index.ts +10 -0
  47. package/src/engine/tkx.ts +62 -0
@@ -0,0 +1,239 @@
1
+ import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useTheme } from '../themes';
4
+ import { sanitizeString } from '../engine/security';
5
+ import { useReducedMotion } from '../hooks';
6
+ import { tkx } from '../engine/tkx';
7
+
8
+ // ── Types ────────────────────────────────────────────────────────────────────
9
+
10
+ export interface TourStep {
11
+ target: string;
12
+ title: string;
13
+ description: string;
14
+ placement?: 'top' | 'bottom' | 'left' | 'right';
15
+ }
16
+
17
+ export interface TkxTourProps {
18
+ steps: TourStep[];
19
+ isOpen?: boolean;
20
+ onClose?: () => void;
21
+ current?: number;
22
+ onChange?: (step: number) => void;
23
+ }
24
+
25
+ // ── Helpers ──────────────────────────────────────────────────────────────────
26
+
27
+ function getRect(selector: string): DOMRect | null {
28
+ const el = document.querySelector(selector);
29
+ return el ? el.getBoundingClientRect() : null;
30
+ }
31
+
32
+ interface PopoverPos {
33
+ top: number;
34
+ left: number;
35
+ }
36
+
37
+ function computePosition(
38
+ rect: DOMRect,
39
+ placement: string,
40
+ tipW: number,
41
+ tipH: number,
42
+ ): PopoverPos {
43
+ const gap = 12;
44
+ switch (placement) {
45
+ case 'top':
46
+ return { top: rect.top - tipH - gap + window.scrollY, left: rect.left + rect.width / 2 - tipW / 2 + window.scrollX };
47
+ case 'bottom':
48
+ return { top: rect.bottom + gap + window.scrollY, left: rect.left + rect.width / 2 - tipW / 2 + window.scrollX };
49
+ case 'left':
50
+ return { top: rect.top + rect.height / 2 - tipH / 2 + window.scrollY, left: rect.left - tipW - gap + window.scrollX };
51
+ case 'right':
52
+ return { top: rect.top + rect.height / 2 - tipH / 2 + window.scrollY, left: rect.right + gap + window.scrollX };
53
+ default:
54
+ return { top: rect.bottom + gap + window.scrollY, left: rect.left + window.scrollX };
55
+ }
56
+ }
57
+
58
+ // ── Component ────────────────────────────────────────────────────────────────
59
+
60
+ export function TkxTour({
61
+ steps,
62
+ isOpen = false,
63
+ onClose,
64
+ current: controlledCurrent,
65
+ onChange,
66
+ }: TkxTourProps) {
67
+ const theme = useTheme();
68
+ const reducedMotion = useReducedMotion();
69
+ const [internal, setInternal] = useState(0);
70
+ const current = controlledCurrent ?? internal;
71
+ const tipRef = useRef<HTMLDivElement>(null);
72
+ const [pos, setPos] = useState<PopoverPos>({ top: 0, left: 0 });
73
+ const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
74
+
75
+ const step = steps[current];
76
+
77
+ const updatePosition = useCallback(() => {
78
+ if (!step) return;
79
+ const rect = getRect(step.target);
80
+ setTargetRect(rect);
81
+ if (rect && tipRef.current) {
82
+ const tipW = tipRef.current.offsetWidth;
83
+ const tipH = tipRef.current.offsetHeight;
84
+ setPos(computePosition(rect, step.placement ?? 'bottom', tipW, tipH));
85
+ }
86
+ }, [step]);
87
+
88
+ useEffect(() => {
89
+ if (!isOpen) return;
90
+ updatePosition();
91
+ window.addEventListener('resize', updatePosition);
92
+ window.addEventListener('scroll', updatePosition, true);
93
+ return () => {
94
+ window.removeEventListener('resize', updatePosition);
95
+ window.removeEventListener('scroll', updatePosition, true);
96
+ };
97
+ }, [isOpen, current, updatePosition]);
98
+
99
+ // Focus trap: refocus popover on step change
100
+ useEffect(() => {
101
+ if (isOpen) tipRef.current?.focus();
102
+ }, [isOpen, current]);
103
+
104
+ const goTo = useCallback(
105
+ (idx: number) => {
106
+ setInternal(idx);
107
+ onChange?.(idx);
108
+ },
109
+ [onChange],
110
+ );
111
+
112
+ const handleNext = useCallback(() => {
113
+ if (current < steps.length - 1) goTo(current + 1);
114
+ else onClose?.();
115
+ }, [current, steps.length, goTo, onClose]);
116
+
117
+ const handlePrev = useCallback(() => {
118
+ if (current > 0) goTo(current - 1);
119
+ }, [current, goTo]);
120
+
121
+ if (!isOpen || !step) return null;
122
+
123
+ const pad = 6;
124
+ const safeTitle = sanitizeString(step.title);
125
+ const safeDesc = sanitizeString(step.description);
126
+ const TIP_W = 300;
127
+
128
+ const overlay = (
129
+ <div aria-hidden="true">
130
+ {/* Dimming overlay with spotlight cutout via CSS clip-path */}
131
+ <div
132
+ style={{
133
+ position: 'fixed',
134
+ inset: 0,
135
+ zIndex: 9998,
136
+ backgroundColor: 'rgba(0,0,0,0.55)',
137
+ clipPath: targetRect
138
+ ? `polygon(0% 0%, 0% 100%, ${targetRect.left - pad}px 100%, ${targetRect.left - pad}px ${targetRect.top - pad}px, ${targetRect.right + pad}px ${targetRect.top - pad}px, ${targetRect.right + pad}px ${targetRect.bottom + pad}px, ${targetRect.left - pad}px ${targetRect.bottom + pad}px, ${targetRect.left - pad}px 100%, 100% 100%, 100% 0%)`
139
+ : undefined,
140
+ transition: reducedMotion ? 'none' : 'clip-path 0.25s ease',
141
+ }}
142
+ onClick={onClose}
143
+ />
144
+
145
+ {/* Popover */}
146
+ <div
147
+ ref={tipRef}
148
+ role="dialog"
149
+ aria-modal="true"
150
+ aria-label={`Tour step ${current + 1} of ${steps.length}`}
151
+ tabIndex={-1}
152
+ style={{
153
+ position: 'absolute',
154
+ zIndex: 9999,
155
+ top: pos.top,
156
+ left: pos.left,
157
+ width: TIP_W,
158
+ backgroundColor: theme.surface,
159
+ border: `1px solid ${theme.border}`,
160
+ borderRadius: 10,
161
+ padding: 20,
162
+ boxShadow: `0 8px 24px ${theme.bg}80`,
163
+ animation: reducedMotion ? 'none' : 'tkxFadeIn 0.2s ease',
164
+ fontFamily: 'inherit',
165
+ }}
166
+ onKeyDown={(e) => {
167
+ if (e.key === 'Escape') onClose?.();
168
+ }}
169
+ >
170
+ <h3
171
+ className={tkx('m-0 mb-2 text-base font-semibold')}
172
+ style={{ color: theme.text }}
173
+ >
174
+ {safeTitle}
175
+ </h3>
176
+ <p
177
+ className={tkx('m-0 mb-4 text-sm leading-relaxed')}
178
+ style={{ color: theme.textMuted }}
179
+ >
180
+ {safeDesc}
181
+ </p>
182
+
183
+ {/* Step dots */}
184
+ <div className={tkx('flex items-center gap-1 mb-4')} aria-label="Tour progress">
185
+ {steps.map((_, i) => (
186
+ <span
187
+ key={i}
188
+ aria-hidden="true"
189
+ style={{
190
+ width: 8,
191
+ height: 8,
192
+ borderRadius: '50%',
193
+ backgroundColor: i === current ? theme.primary : theme.border,
194
+ transition: reducedMotion ? 'none' : 'background-color 0.2s',
195
+ }}
196
+ />
197
+ ))}
198
+ </div>
199
+
200
+ {/* Navigation */}
201
+ <div className={tkx('flex items-center justify-between gap-2')}>
202
+ <button
203
+ type="button"
204
+ aria-label="Skip tour"
205
+ onClick={onClose}
206
+ className={tkx('border-0 bg-transparent cursor-pointer text-sm px-2 py-1')}
207
+ style={{ color: theme.textMuted }}
208
+ >
209
+ Skip
210
+ </button>
211
+ <div className={tkx('flex gap-2')}>
212
+ {current > 0 && (
213
+ <button
214
+ type="button"
215
+ aria-label="Previous step"
216
+ onClick={handlePrev}
217
+ className={tkx('rounded-md border px-3 py-1 text-sm cursor-pointer bg-transparent')}
218
+ style={{ borderColor: theme.border, color: theme.text }}
219
+ >
220
+ Prev
221
+ </button>
222
+ )}
223
+ <button
224
+ type="button"
225
+ aria-label={current === steps.length - 1 ? 'Finish tour' : 'Next step'}
226
+ onClick={handleNext}
227
+ className={tkx('rounded-md border-0 px-4 py-1 text-sm cursor-pointer font-medium')}
228
+ style={{ backgroundColor: theme.primary, color: '#fff' }}
229
+ >
230
+ {current === steps.length - 1 ? 'Finish' : 'Next'}
231
+ </button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ );
237
+
238
+ return createPortal(overlay, document.body);
239
+ }
@@ -0,0 +1,143 @@
1
+ import { type ReactNode, useRef, useEffect, useMemo, useState, useCallback } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useReducedMotion } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ // ── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface TkxWatermarkProps {
10
+ text: string | string[];
11
+ children: ReactNode;
12
+ rotate?: number;
13
+ gap?: [number, number];
14
+ fontSize?: number;
15
+ color?: string;
16
+ zIndex?: number;
17
+ }
18
+
19
+ // ── Canvas Renderer ──────────────────────────────────────────────────────────
20
+
21
+ function renderWatermarkPattern(
22
+ lines: string[],
23
+ rotate: number,
24
+ gap: [number, number],
25
+ fontSize: number,
26
+ fillColor: string,
27
+ ): string {
28
+ const canvas = document.createElement('canvas');
29
+ const ctx = canvas.getContext('2d');
30
+ if (!ctx) return '';
31
+
32
+ const lineHeight = fontSize * 1.5;
33
+ const totalTextHeight = lines.length * lineHeight;
34
+
35
+ // Calculate canvas dimensions based on text length and gap
36
+ const maxTextWidth = Math.max(...lines.map((l) => l.length)) * fontSize * 0.6;
37
+ const canvasW = gap[0] + maxTextWidth;
38
+ const canvasH = gap[1] + totalTextHeight;
39
+
40
+ // High-DPI rendering
41
+ const dpr = 2;
42
+ canvas.width = canvasW * dpr;
43
+ canvas.height = canvasH * dpr;
44
+ ctx.scale(dpr, dpr);
45
+
46
+ // Move origin to center for rotation
47
+ ctx.translate(canvasW / 2, canvasH / 2);
48
+ ctx.rotate((rotate * Math.PI) / 180);
49
+
50
+ // Text styling
51
+ ctx.font = `${fontSize}px sans-serif`;
52
+ ctx.fillStyle = fillColor;
53
+ ctx.textAlign = 'center';
54
+ ctx.textBaseline = 'middle';
55
+
56
+ // Draw each line centered
57
+ lines.forEach((line, i) => {
58
+ const y = (i - (lines.length - 1) / 2) * lineHeight;
59
+ ctx.fillText(line, 0, y);
60
+ });
61
+
62
+ return canvas.toDataURL();
63
+ }
64
+
65
+ // ── Component ────────────────────────────────────────────────────────────────
66
+
67
+ export function TkxWatermark({
68
+ text,
69
+ children,
70
+ rotate = -22,
71
+ gap = [100, 100],
72
+ fontSize = 14,
73
+ color,
74
+ zIndex = 10,
75
+ }: TkxWatermarkProps) {
76
+ const theme = useTheme();
77
+ const reducedMotion = useReducedMotion();
78
+ const containerRef = useRef<HTMLDivElement>(null);
79
+ const [bgImage, setBgImage] = useState('');
80
+
81
+ // Sanitize text lines
82
+ const lines = useMemo(
83
+ () => (Array.isArray(text) ? text : [text]).map((t) => sanitizeString(t)),
84
+ [text],
85
+ );
86
+
87
+ const fillColor = color ?? `${theme.textMuted}22`;
88
+
89
+ // Memoize the render function
90
+ const generatePattern = useCallback(() => {
91
+ return renderWatermarkPattern(lines, rotate, gap, fontSize, fillColor);
92
+ }, [lines, rotate, gap, fontSize, fillColor]);
93
+
94
+ // Generate the watermark canvas pattern
95
+ useEffect(() => {
96
+ const dataUrl = generatePattern();
97
+ if (dataUrl) {
98
+ setBgImage(`url(${dataUrl})`);
99
+ }
100
+ }, [generatePattern]);
101
+
102
+ // Prevent tampering via MutationObserver
103
+ useEffect(() => {
104
+ const overlay = containerRef.current?.querySelector<HTMLElement>('[data-watermark]');
105
+ if (!overlay) return;
106
+
107
+ const observer = new MutationObserver(() => {
108
+ // Re-apply watermark if someone modifies it
109
+ const dataUrl = generatePattern();
110
+ if (dataUrl) {
111
+ overlay.style.backgroundImage = `url(${dataUrl})`;
112
+ }
113
+ });
114
+
115
+ observer.observe(overlay, {
116
+ attributes: true,
117
+ attributeFilter: ['style'],
118
+ });
119
+
120
+ return () => observer.disconnect();
121
+ }, [generatePattern]);
122
+
123
+ return (
124
+ <div
125
+ ref={containerRef}
126
+ className={tkx('relative')}
127
+ style={{ overflow: 'hidden' }}
128
+ >
129
+ {children}
130
+ <div
131
+ data-watermark
132
+ aria-hidden="true"
133
+ className={tkx('absolute inset-0 pointer-events-none')}
134
+ style={{
135
+ zIndex,
136
+ backgroundImage: bgImage,
137
+ backgroundRepeat: 'repeat',
138
+ animation: reducedMotion ? 'none' : 'tkxFadeIn 0.3s ease',
139
+ }}
140
+ />
141
+ </div>
142
+ );
143
+ }
@@ -58,3 +58,13 @@ export * from './TkxTypography';
58
58
  export * from './TkxSpin';
59
59
  export * from './TkxEmpty';
60
60
  export * from './TkxStatistic';
61
+ export * from './TkxSegmented';
62
+ export * from './TkxMentions';
63
+ export * from './TkxQRCode';
64
+ export * from './TkxResult';
65
+ export * from './TkxTour';
66
+ export * from './TkxWatermark';
67
+ export * from './TkxAffix';
68
+ export * from './TkxAnchor';
69
+ export * from './TkxCascader';
70
+ export * from './TkxList';
package/src/engine/tkx.ts CHANGED
@@ -660,6 +660,10 @@ function resolveUtility(u: string): Declarations | null {
660
660
  // ── CSS variable passthrough: [--my-var:value] ────────────────────────────
661
661
  if ((m = u.match(/^\[(--[a-zA-Z0-9-]+):(.+)]$/))) { return { [m[1]]: m[2] }; }
662
662
 
663
+ // ── Plugin utilities ─────────────────────────────────────────────────────
664
+ const pluginResult = resolvePluginUtility(u);
665
+ if (pluginResult) return pluginResult;
666
+
663
667
  return null;
664
668
  }
665
669
 
@@ -872,3 +876,61 @@ export function resetAtomicCSS(): void {
872
876
  export function cx(...classes: (string | false | null | undefined)[]): string {
873
877
  return classes.filter(Boolean).join(' ');
874
878
  }
879
+
880
+ // ── Plugin System ────────────────────────────────────────────────────────────
881
+ /**
882
+ * TKX Plugin API — extend the utility engine with custom utilities.
883
+ *
884
+ * @example
885
+ * ```tsx
886
+ * import { tkxPlugin, tkx } from '@tekivex/ui';
887
+ *
888
+ * // Register custom utilities
889
+ * tkxPlugin({
890
+ * name: 'brand',
891
+ * utilities: {
892
+ * 'brand-gradient': { background: 'linear-gradient(135deg, #667eea, #764ba2)' },
893
+ * 'brand-text': { color: '#667eea', fontWeight: '700' },
894
+ * 'card-shadow': { boxShadow: '0 8px 32px rgba(102, 126, 234, 0.15)' },
895
+ * 'glass': {
896
+ * background: 'rgba(255, 255, 255, 0.05)',
897
+ * backdropFilter: 'blur(12px)',
898
+ * border: '1px solid rgba(255, 255, 255, 0.1)',
899
+ * },
900
+ * },
901
+ * });
902
+ *
903
+ * // Use them like any built-in utility
904
+ * const cls = tkx('brand-gradient p-6 rounded-xl card-shadow');
905
+ * ```
906
+ */
907
+
908
+ export interface TkxPluginDef {
909
+ name: string;
910
+ utilities: Record<string, Record<string, string>>;
911
+ }
912
+
913
+ const pluginRegistry = new Map<string, Record<string, Record<string, string>>>();
914
+
915
+ export function tkxPlugin(plugin: TkxPluginDef): void {
916
+ pluginRegistry.set(plugin.name, plugin.utilities);
917
+ }
918
+
919
+ export function tkxRemovePlugin(name: string): void {
920
+ pluginRegistry.delete(name);
921
+ }
922
+
923
+ export function tkxListPlugins(): string[] {
924
+ return Array.from(pluginRegistry.keys());
925
+ }
926
+
927
+ /**
928
+ * Resolve a plugin utility to CSS properties.
929
+ * Called internally by the TKX engine — but also exported for inspection.
930
+ */
931
+ export function resolvePluginUtility(name: string): Record<string, string> | null {
932
+ for (const utilities of pluginRegistry.values()) {
933
+ if (utilities[name]) return utilities[name];
934
+ }
935
+ return null;
936
+ }