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.
- package/dist/TkxForm-BWK4LqY3.js +1461 -0
- package/dist/TkxForm-ljQjX7KD.cjs +15 -0
- package/dist/charts.cjs +1 -0
- package/dist/charts.js +480 -0
- package/dist/headless.cjs +1 -0
- package/dist/headless.js +258 -0
- package/dist/index-BINBzXuY.cjs +2 -0
- package/dist/index-eT_U4qB2.js +512 -0
- package/dist/index.cjs +63 -14
- package/dist/index.d.ts +33 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11396 -6336
- package/dist/src/charts/TkxAreaChart.d.ts +29 -0
- package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
- package/dist/src/charts/TkxBarChart.d.ts +28 -0
- package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
- package/dist/src/charts/TkxDonutChart.d.ts +23 -0
- package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
- package/dist/src/charts/TkxLineChart.d.ts +35 -0
- package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
- package/dist/src/charts/TkxPieChart.d.ts +19 -0
- package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
- package/dist/src/charts/TkxRadarChart.d.ts +20 -0
- package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
- package/dist/src/charts/TkxScatterChart.d.ts +28 -0
- package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
- package/dist/src/charts/index.d.ts +15 -0
- package/dist/src/charts/index.d.ts.map +1 -0
- package/dist/src/charts/shared.d.ts +33 -0
- package/dist/src/charts/shared.d.ts.map +1 -0
- package/dist/src/components/TkxAffix.d.ts +10 -0
- package/dist/src/components/TkxAffix.d.ts.map +1 -0
- package/dist/src/components/TkxAnchor.d.ts +14 -0
- package/dist/src/components/TkxAnchor.d.ts.map +1 -0
- package/dist/src/components/TkxCascader.d.ts +16 -0
- package/dist/src/components/TkxCascader.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +19 -1
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
- package/dist/src/components/TkxForm.d.ts +33 -3
- package/dist/src/components/TkxForm.d.ts.map +1 -1
- package/dist/src/components/TkxImage.d.ts.map +1 -1
- package/dist/src/components/TkxList.d.ts +24 -0
- package/dist/src/components/TkxList.d.ts.map +1 -0
- package/dist/src/components/TkxMentions.d.ts +15 -0
- package/dist/src/components/TkxMentions.d.ts.map +1 -0
- package/dist/src/components/TkxQRCode.d.ts +11 -0
- package/dist/src/components/TkxQRCode.d.ts.map +1 -0
- package/dist/src/components/TkxResult.d.ts +11 -0
- package/dist/src/components/TkxResult.d.ts.map +1 -0
- package/dist/src/components/TkxSegmented.d.ts +16 -0
- package/dist/src/components/TkxSegmented.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts +5 -1
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxTour.d.ts +15 -0
- package/dist/src/components/TkxTour.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +1 -1
- package/dist/src/components/TkxTypography.d.ts.map +1 -1
- package/dist/src/components/TkxWatermark.d.ts +12 -0
- package/dist/src/components/TkxWatermark.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +10 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/engine/tkx.d.ts +38 -0
- package/dist/src/engine/tkx.d.ts.map +1 -1
- package/dist/src/headless/index.d.ts +18 -0
- package/dist/src/headless/index.d.ts.map +1 -0
- package/dist/src/headless/useControllable.d.ts +20 -0
- package/dist/src/headless/useControllable.d.ts.map +1 -0
- package/dist/src/headless/useDebounce.d.ts +14 -0
- package/dist/src/headless/useDebounce.d.ts.map +1 -0
- package/dist/src/headless/useDisclosure.d.ts +20 -0
- package/dist/src/headless/useDisclosure.d.ts.map +1 -0
- package/dist/src/headless/useFormState.d.ts +44 -0
- package/dist/src/headless/useFormState.d.ts.map +1 -0
- package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
- package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
- package/dist/src/headless/useListSelection.d.ts +29 -0
- package/dist/src/headless/useListSelection.d.ts.map +1 -0
- package/dist/src/headless/useLocalStorage.d.ts +9 -0
- package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
- package/dist/src/headless/useMediaQuery.d.ts +21 -0
- package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
- package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
- package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
- package/dist/src/headless/useThrottle.d.ts +9 -0
- package/dist/src/headless/useThrottle.d.ts.map +1 -0
- package/dist/src/i18n/index.d.ts +47 -2
- package/dist/src/i18n/index.d.ts.map +1 -1
- package/dist/src/themes/index.d.ts.map +1 -1
- package/package.json +61 -7
- package/src/charts/TkxAreaChart.tsx +119 -0
- package/src/charts/TkxBarChart.tsx +115 -0
- package/src/charts/TkxDonutChart.tsx +145 -0
- package/src/charts/TkxLineChart.tsx +135 -0
- package/src/charts/TkxPieChart.tsx +82 -0
- package/src/charts/TkxRadarChart.tsx +90 -0
- package/src/charts/TkxScatterChart.tsx +102 -0
- package/src/charts/index.ts +20 -0
- package/src/charts/shared.ts +45 -0
- package/src/components/TkxAffix.tsx +138 -0
- package/src/components/TkxAnchor.tsx +193 -0
- package/src/components/TkxCascader.tsx +276 -0
- package/src/components/TkxDataGrid.tsx +557 -230
- package/src/components/TkxForm.tsx +67 -15
- package/src/components/TkxImage.tsx +1 -1
- package/src/components/TkxList.tsx +242 -0
- package/src/components/TkxMentions.tsx +210 -0
- package/src/components/TkxQRCode.tsx +174 -0
- package/src/components/TkxResult.tsx +128 -0
- package/src/components/TkxSegmented.tsx +169 -0
- package/src/components/TkxSelect.tsx +209 -6
- package/src/components/TkxTour.tsx +239 -0
- package/src/components/TkxTypography.tsx +4 -2
- package/src/components/TkxWatermark.tsx +143 -0
- package/src/components/index.ts +10 -0
- package/src/engine/tkx.ts +122 -3
- package/src/headless/index.ts +44 -0
- package/src/headless/useControllable.ts +40 -0
- package/src/headless/useDebounce.ts +35 -0
- package/src/headless/useDisclosure.ts +28 -0
- package/src/headless/useFormState.ts +111 -0
- package/src/headless/useIntersectionObserver.ts +51 -0
- package/src/headless/useListSelection.ts +101 -0
- package/src/headless/useLocalStorage.ts +46 -0
- package/src/headless/useMediaQuery.ts +37 -0
- package/src/headless/useRovingTabIndex.ts +84 -0
- package/src/headless/useThrottle.ts +46 -0
- package/src/i18n/I18nProvider.tsx +1 -1
- package/src/i18n/index.ts +678 -38
- 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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|