tekivex-ui 2.2.0 → 2.5.1

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 (129) hide show
  1. package/dist/TkxForm-BWK4LqY3.js +1461 -0
  2. package/dist/TkxForm-ljQjX7KD.cjs +15 -0
  3. package/dist/charts.cjs +1 -0
  4. package/dist/charts.js +480 -0
  5. package/dist/headless.cjs +1 -0
  6. package/dist/headless.js +258 -0
  7. package/dist/index-BINBzXuY.cjs +2 -0
  8. package/dist/index-eT_U4qB2.js +512 -0
  9. package/dist/index.cjs +63 -14
  10. package/dist/index.d.ts +33 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +11396 -6336
  13. package/dist/src/charts/TkxAreaChart.d.ts +29 -0
  14. package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
  15. package/dist/src/charts/TkxBarChart.d.ts +28 -0
  16. package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
  17. package/dist/src/charts/TkxDonutChart.d.ts +23 -0
  18. package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
  19. package/dist/src/charts/TkxLineChart.d.ts +35 -0
  20. package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
  21. package/dist/src/charts/TkxPieChart.d.ts +19 -0
  22. package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
  23. package/dist/src/charts/TkxRadarChart.d.ts +20 -0
  24. package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
  25. package/dist/src/charts/TkxScatterChart.d.ts +28 -0
  26. package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
  27. package/dist/src/charts/index.d.ts +15 -0
  28. package/dist/src/charts/index.d.ts.map +1 -0
  29. package/dist/src/charts/shared.d.ts +33 -0
  30. package/dist/src/charts/shared.d.ts.map +1 -0
  31. package/dist/src/components/TkxAffix.d.ts +10 -0
  32. package/dist/src/components/TkxAffix.d.ts.map +1 -0
  33. package/dist/src/components/TkxAnchor.d.ts +14 -0
  34. package/dist/src/components/TkxAnchor.d.ts.map +1 -0
  35. package/dist/src/components/TkxCascader.d.ts +16 -0
  36. package/dist/src/components/TkxCascader.d.ts.map +1 -0
  37. package/dist/src/components/TkxDataGrid.d.ts +19 -1
  38. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  39. package/dist/src/components/TkxForm.d.ts +33 -3
  40. package/dist/src/components/TkxForm.d.ts.map +1 -1
  41. package/dist/src/components/TkxImage.d.ts.map +1 -1
  42. package/dist/src/components/TkxList.d.ts +24 -0
  43. package/dist/src/components/TkxList.d.ts.map +1 -0
  44. package/dist/src/components/TkxMentions.d.ts +15 -0
  45. package/dist/src/components/TkxMentions.d.ts.map +1 -0
  46. package/dist/src/components/TkxQRCode.d.ts +11 -0
  47. package/dist/src/components/TkxQRCode.d.ts.map +1 -0
  48. package/dist/src/components/TkxResult.d.ts +11 -0
  49. package/dist/src/components/TkxResult.d.ts.map +1 -0
  50. package/dist/src/components/TkxSegmented.d.ts +16 -0
  51. package/dist/src/components/TkxSegmented.d.ts.map +1 -0
  52. package/dist/src/components/TkxSelect.d.ts +5 -1
  53. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  54. package/dist/src/components/TkxTour.d.ts +15 -0
  55. package/dist/src/components/TkxTour.d.ts.map +1 -0
  56. package/dist/src/components/TkxTypography.d.ts +1 -1
  57. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  58. package/dist/src/components/TkxWatermark.d.ts +12 -0
  59. package/dist/src/components/TkxWatermark.d.ts.map +1 -0
  60. package/dist/src/components/index.d.ts +10 -0
  61. package/dist/src/components/index.d.ts.map +1 -1
  62. package/dist/src/engine/tkx.d.ts +38 -0
  63. package/dist/src/engine/tkx.d.ts.map +1 -1
  64. package/dist/src/headless/index.d.ts +18 -0
  65. package/dist/src/headless/index.d.ts.map +1 -0
  66. package/dist/src/headless/useControllable.d.ts +20 -0
  67. package/dist/src/headless/useControllable.d.ts.map +1 -0
  68. package/dist/src/headless/useDebounce.d.ts +14 -0
  69. package/dist/src/headless/useDebounce.d.ts.map +1 -0
  70. package/dist/src/headless/useDisclosure.d.ts +20 -0
  71. package/dist/src/headless/useDisclosure.d.ts.map +1 -0
  72. package/dist/src/headless/useFormState.d.ts +44 -0
  73. package/dist/src/headless/useFormState.d.ts.map +1 -0
  74. package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
  75. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
  76. package/dist/src/headless/useListSelection.d.ts +29 -0
  77. package/dist/src/headless/useListSelection.d.ts.map +1 -0
  78. package/dist/src/headless/useLocalStorage.d.ts +9 -0
  79. package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
  80. package/dist/src/headless/useMediaQuery.d.ts +21 -0
  81. package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
  82. package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
  83. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
  84. package/dist/src/headless/useThrottle.d.ts +9 -0
  85. package/dist/src/headless/useThrottle.d.ts.map +1 -0
  86. package/dist/src/i18n/index.d.ts +47 -2
  87. package/dist/src/i18n/index.d.ts.map +1 -1
  88. package/dist/src/themes/index.d.ts.map +1 -1
  89. package/package.json +61 -7
  90. package/src/charts/TkxAreaChart.tsx +119 -0
  91. package/src/charts/TkxBarChart.tsx +115 -0
  92. package/src/charts/TkxDonutChart.tsx +145 -0
  93. package/src/charts/TkxLineChart.tsx +135 -0
  94. package/src/charts/TkxPieChart.tsx +82 -0
  95. package/src/charts/TkxRadarChart.tsx +90 -0
  96. package/src/charts/TkxScatterChart.tsx +102 -0
  97. package/src/charts/index.ts +20 -0
  98. package/src/charts/shared.ts +45 -0
  99. package/src/components/TkxAffix.tsx +138 -0
  100. package/src/components/TkxAnchor.tsx +193 -0
  101. package/src/components/TkxCascader.tsx +276 -0
  102. package/src/components/TkxDataGrid.tsx +557 -230
  103. package/src/components/TkxForm.tsx +67 -15
  104. package/src/components/TkxImage.tsx +1 -1
  105. package/src/components/TkxList.tsx +242 -0
  106. package/src/components/TkxMentions.tsx +210 -0
  107. package/src/components/TkxQRCode.tsx +174 -0
  108. package/src/components/TkxResult.tsx +128 -0
  109. package/src/components/TkxSegmented.tsx +169 -0
  110. package/src/components/TkxSelect.tsx +209 -6
  111. package/src/components/TkxTour.tsx +239 -0
  112. package/src/components/TkxTypography.tsx +4 -2
  113. package/src/components/TkxWatermark.tsx +143 -0
  114. package/src/components/index.ts +10 -0
  115. package/src/engine/tkx.ts +122 -3
  116. package/src/headless/index.ts +44 -0
  117. package/src/headless/useControllable.ts +40 -0
  118. package/src/headless/useDebounce.ts +35 -0
  119. package/src/headless/useDisclosure.ts +28 -0
  120. package/src/headless/useFormState.ts +111 -0
  121. package/src/headless/useIntersectionObserver.ts +51 -0
  122. package/src/headless/useListSelection.ts +101 -0
  123. package/src/headless/useLocalStorage.ts +46 -0
  124. package/src/headless/useMediaQuery.ts +37 -0
  125. package/src/headless/useRovingTabIndex.ts +84 -0
  126. package/src/headless/useThrottle.ts +46 -0
  127. package/src/i18n/I18nProvider.tsx +1 -1
  128. package/src/i18n/index.ts +678 -38
  129. package/src/themes/index.ts +31 -4
@@ -44,6 +44,10 @@ export interface TkxSelectProps {
44
44
  onChange?: (value: string | string[]) => void;
45
45
  renderOption?: (option: SelectOption, isSelected: boolean) => ReactNode;
46
46
  maxMenuHeight?: number;
47
+ /** Enable virtual scrolling for the dropdown. Defaults to auto (enabled when 100+ options). */
48
+ virtualScroll?: boolean;
49
+ /** Fixed option height in pixels for virtual scroll calculations. Default: 36 */
50
+ optionHeight?: number;
47
51
  id?: string;
48
52
  className?: string;
49
53
  style?: CSSProperties;
@@ -139,6 +143,8 @@ export function TkxSelect({
139
143
  onChange,
140
144
  renderOption,
141
145
  maxMenuHeight = 280,
146
+ virtualScroll,
147
+ optionHeight = 36,
142
148
  id: idProp,
143
149
  className,
144
150
  style,
@@ -172,6 +178,32 @@ export function TkxSelect({
172
178
  const sz = SIZE_MAP[size];
173
179
  const hasError = isInvalid || !!errorMessage;
174
180
 
181
+ // ── Virtual scroll for options ─────────────────────────────────────────
182
+
183
+ const optionsListRef = useRef<HTMLDivElement>(null);
184
+ const [optScrollTop, setOptScrollTop] = useState(0);
185
+ const [optContainerHeight, setOptContainerHeight] = useState(0);
186
+
187
+ const handleOptScroll = useCallback(() => {
188
+ if (!optionsListRef.current) return;
189
+ setOptScrollTop(optionsListRef.current.scrollTop);
190
+ }, []);
191
+
192
+ useEffect(() => {
193
+ if (!isOpen) {
194
+ setOptScrollTop(0);
195
+ return;
196
+ }
197
+ const el = optionsListRef.current;
198
+ if (!el) return;
199
+ setOptContainerHeight(el.clientHeight);
200
+ const ro = new ResizeObserver(() => {
201
+ setOptContainerHeight(el.clientHeight);
202
+ });
203
+ ro.observe(el);
204
+ return () => ro.disconnect();
205
+ }, [isOpen]);
206
+
175
207
  // ── Filtered + grouped options ────────────────────────────────────────────
176
208
 
177
209
  const filteredOptions = searchable && search
@@ -182,6 +214,25 @@ export function TkxSelect({
182
214
 
183
215
  const flatEnabled = filteredOptions.filter((o) => !o.disabled);
184
216
 
217
+ const hasGroups = filteredOptions.some((o) => !!o.group);
218
+ const isVirtualSelect =
219
+ virtualScroll !== undefined
220
+ ? virtualScroll
221
+ : filteredOptions.length >= 100;
222
+ // Only virtualize when we have a flat list (no groups) for simplicity
223
+ const useVirtual = isVirtualSelect && !hasGroups;
224
+
225
+ const OPTION_OVERSCAN = 8;
226
+ const virtualTotalHeight = filteredOptions.length * optionHeight;
227
+ const vStartIndex = useVirtual
228
+ ? Math.max(0, Math.floor(optScrollTop / optionHeight) - OPTION_OVERSCAN)
229
+ : 0;
230
+ const vEndIndex = useVirtual
231
+ ? Math.min(filteredOptions.length, Math.ceil((optScrollTop + optContainerHeight) / optionHeight) + OPTION_OVERSCAN)
232
+ : filteredOptions.length;
233
+ const visibleOptions = useVirtual ? filteredOptions.slice(vStartIndex, vEndIndex) : filteredOptions;
234
+ const vOffsetY = vStartIndex * optionHeight;
235
+
185
236
  const groups = filteredOptions.reduce<Record<string, SelectOption[]>>(
186
237
  (acc, opt) => {
187
238
  const g = opt.group ?? '';
@@ -285,11 +336,23 @@ export function TkxSelect({
285
336
 
286
337
  useEffect(() => {
287
338
  if (!isOpen || activeIndex < 0) return;
288
- const el = listRef.current?.querySelector(
289
- `[data-idx="${activeIndex}"]`,
290
- ) as HTMLElement | null;
291
- el?.scrollIntoView({ block: 'nearest' });
292
- }, [activeIndex, isOpen]);
339
+ if (useVirtual && optionsListRef.current) {
340
+ // For virtual scroll, compute position directly
341
+ const itemTop = activeIndex * optionHeight;
342
+ const itemBottom = itemTop + optionHeight;
343
+ const el = optionsListRef.current;
344
+ if (itemTop < el.scrollTop) {
345
+ el.scrollTop = itemTop;
346
+ } else if (itemBottom > el.scrollTop + el.clientHeight) {
347
+ el.scrollTop = itemBottom - el.clientHeight;
348
+ }
349
+ } else {
350
+ const el = listRef.current?.querySelector(
351
+ `[data-idx="${activeIndex}"]`,
352
+ ) as HTMLElement | null;
353
+ el?.scrollIntoView({ block: 'nearest' });
354
+ }
355
+ }, [activeIndex, isOpen, useVirtual, optionHeight]);
293
356
 
294
357
  // ── Keyboard: trigger button ───────────────────────────────────────────────
295
358
 
@@ -494,13 +557,151 @@ export function TkxSelect({
494
557
 
495
558
  {/* Options list */}
496
559
  <div
497
- ref={listRef}
560
+ ref={(el) => {
561
+ (listRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
562
+ (optionsListRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
563
+ }}
564
+ onScroll={useVirtual ? handleOptScroll : undefined}
498
565
  style={{
499
566
  overflowY: 'auto',
500
567
  flexGrow: 1,
501
568
  maxHeight: maxMenuHeight - (searchable ? 48 : 0),
502
569
  }}
503
570
  >
571
+ {useVirtual ? (
572
+ /* Virtualized flat list (no groups) */
573
+ <div style={{ height: virtualTotalHeight, position: 'relative' }}>
574
+ <div style={{ position: 'absolute', top: vOffsetY, left: 0, right: 0 }}>
575
+ {visibleOptions.map((opt) => {
576
+ const flatIdx = flatEnabled.indexOf(opt);
577
+ const isActive = flatIdx === activeIndex;
578
+ const isSelected = selectedValues.includes(opt.value);
579
+
580
+ const optionContent = renderOption ? (
581
+ renderOption(opt, isSelected)
582
+ ) : (
583
+ <span
584
+ style={{
585
+ display: 'flex',
586
+ flexDirection: 'column',
587
+ flex: 1,
588
+ minWidth: 0,
589
+ }}
590
+ >
591
+ <span
592
+ style={{
593
+ display: 'flex',
594
+ alignItems: 'center',
595
+ gap: 8,
596
+ }}
597
+ >
598
+ {opt.icon && (
599
+ <span style={{ flexShrink: 0, display: 'flex' }}>
600
+ {opt.icon}
601
+ </span>
602
+ )}
603
+ <span
604
+ style={{
605
+ overflow: 'hidden',
606
+ textOverflow: 'ellipsis',
607
+ whiteSpace: 'nowrap',
608
+ }}
609
+ >
610
+ {sanitizeString(opt.label)}
611
+ </span>
612
+ </span>
613
+ {opt.description && (
614
+ <span
615
+ style={{
616
+ fontSize: '12px',
617
+ color: theme.textMuted,
618
+ marginTop: 1,
619
+ overflow: 'hidden',
620
+ textOverflow: 'ellipsis',
621
+ whiteSpace: 'nowrap',
622
+ }}
623
+ >
624
+ {sanitizeString(opt.description)}
625
+ </span>
626
+ )}
627
+ </span>
628
+ );
629
+
630
+ return (
631
+ <div
632
+ key={opt.value}
633
+ id={`${id}-opt-${opt.value}`}
634
+ data-idx={flatIdx >= 0 ? flatIdx : undefined}
635
+ role="option"
636
+ aria-selected={isSelected}
637
+ aria-disabled={opt.disabled || undefined}
638
+ onClick={() => !opt.disabled && commitValue(opt.value)}
639
+ onMouseEnter={() =>
640
+ !opt.disabled && flatIdx >= 0 && setActiveIndex(flatIdx)
641
+ }
642
+ style={{
643
+ height: optionHeight,
644
+ boxSizing: 'border-box',
645
+ padding: `0 ${sz.px}`,
646
+ fontSize: sz.fontSize,
647
+ fontFamily: 'inherit',
648
+ color: opt.disabled ? theme.textMuted : theme.text,
649
+ backgroundColor: isActive
650
+ ? `${theme.primary}22`
651
+ : isSelected
652
+ ? `${theme.primary}12`
653
+ : 'transparent',
654
+ cursor: opt.disabled ? 'not-allowed' : 'pointer',
655
+ display: 'flex',
656
+ alignItems: 'center',
657
+ justifyContent: 'space-between',
658
+ gap: 8,
659
+ transition: 'background-color 80ms',
660
+ opacity: opt.disabled ? 0.5 : 1,
661
+ userSelect: 'none',
662
+ touchAction: 'manipulation',
663
+ }}
664
+ >
665
+ {optionContent}
666
+ {isSelected && !renderOption && (
667
+ <svg
668
+ width="14"
669
+ height="14"
670
+ viewBox="0 0 24 24"
671
+ fill="none"
672
+ stroke={theme.primary}
673
+ strokeWidth="2.5"
674
+ aria-hidden="true"
675
+ style={{ flexShrink: 0 }}
676
+ >
677
+ <path d="M20 6L9 17l-5-5" />
678
+ </svg>
679
+ )}
680
+ </div>
681
+ );
682
+ })}
683
+ </div>
684
+ {filteredOptions.length === 0 && (
685
+ <div
686
+ style={{
687
+ padding: `${sz.py} ${sz.px}`,
688
+ fontSize: sz.fontSize,
689
+ fontFamily: 'inherit',
690
+ color: theme.textMuted,
691
+ textAlign: 'center',
692
+ position: 'absolute',
693
+ top: 0,
694
+ left: 0,
695
+ right: 0,
696
+ }}
697
+ >
698
+ {isLoading ? 'Loading\u2026' : 'No options found'}
699
+ </div>
700
+ )}
701
+ </div>
702
+ ) : (
703
+ /* Non-virtual: grouped rendering */
704
+ <>
504
705
  {Object.entries(groups).map(([group, groupOpts]) => (
505
706
  <div key={group}>
506
707
  {group && (
@@ -646,6 +847,8 @@ export function TkxSelect({
646
847
  {isLoading ? 'Loading…' : 'No options found'}
647
848
  </div>
648
849
  )}
850
+ </>
851
+ )}
649
852
  </div>
650
853
  </div>,
651
854
  document.body,
@@ -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
+ }
@@ -206,12 +206,14 @@ export function TkxText({
206
206
  );
207
207
  }
208
208
 
209
+ // Use semantic HTML elements when appropriate
210
+ const tag = strong ? 'strong' : italic ? 'em' : 'span';
211
+
209
212
  return createElement(
210
- 'span',
213
+ tag,
211
214
  {
212
215
  style: {
213
216
  color,
214
- fontWeight: strong ? 600 : 'inherit',
215
217
  fontStyle: italic ? 'italic' : 'normal',
216
218
  textDecoration: decorations.length > 0 ? decorations.join(' ') : 'none',
217
219
  ...style,
@@ -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';