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,49 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
2
|
+
import { useTheme } from '../themes';
|
|
3
|
+
import { sanitizeString } from '../engine/security';
|
|
4
|
+
import { tkx, cx } from '../engine/tkx';
|
|
5
|
+
|
|
6
|
+
export interface TkxDividerProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
orientation?: 'horizontal' | 'vertical';
|
|
8
|
+
label?: string;
|
|
9
|
+
variant?: 'solid' | 'dashed' | 'dotted';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const TkxDivider = forwardRef<HTMLHRElement, TkxDividerProps>(
|
|
13
|
+
({ orientation = 'horizontal', label, variant = 'solid', className, style, ...rest }, ref) => {
|
|
14
|
+
const theme = useTheme();
|
|
15
|
+
const safeLabel = label ? sanitizeString(label) : undefined;
|
|
16
|
+
const isVertical = orientation === 'vertical';
|
|
17
|
+
const borderProp = isVertical ? 'borderLeft' : 'borderTop';
|
|
18
|
+
const borderVal = `1px ${variant} ${theme.border}`;
|
|
19
|
+
|
|
20
|
+
if (safeLabel) {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
role="separator"
|
|
24
|
+
aria-orientation={orientation}
|
|
25
|
+
className={cx(tkx('flex items-center gap-3 text-xs'), className)}
|
|
26
|
+
style={{ color: theme.textMuted, ...style }}
|
|
27
|
+
{...(rest as HTMLAttributes<HTMLDivElement>)}
|
|
28
|
+
>
|
|
29
|
+
<hr className={tkx('flex-1 m-0 border-none')} style={{ [borderProp]: borderVal }} />
|
|
30
|
+
<span>{safeLabel}</span>
|
|
31
|
+
<hr className={tkx('flex-1 m-0 border-none')} style={{ [borderProp]: borderVal }} />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<hr
|
|
38
|
+
ref={ref}
|
|
39
|
+
role="separator"
|
|
40
|
+
aria-orientation={orientation}
|
|
41
|
+
className={cx(tkx(isVertical ? 'inline-block self-stretch border-none w-0 h-full' : 'w-full border-none m-0'), className)}
|
|
42
|
+
style={{ [borderProp]: borderVal, ...style }}
|
|
43
|
+
{...rest}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
TkxDivider.displayName = 'TkxDivider';
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useId,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
} from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
7
|
+
import { useTheme } from '../themes';
|
|
8
|
+
import { sanitizeString } from '../engine/security';
|
|
9
|
+
import { useFocusTrap, useEscapeKey, useReducedMotion } from '../hooks';
|
|
10
|
+
import { tkx } from '../engine/tkx';
|
|
11
|
+
|
|
12
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom';
|
|
15
|
+
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
16
|
+
|
|
17
|
+
export interface TkxDrawerProps {
|
|
18
|
+
isOpen: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
placement?: DrawerPlacement;
|
|
21
|
+
size?: DrawerSize;
|
|
22
|
+
title?: ReactNode;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
footer?: ReactNode;
|
|
25
|
+
closeOnOverlayClick?: boolean;
|
|
26
|
+
closeOnEsc?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Size map ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SIZE_MAP: Record<DrawerSize, string> = {
|
|
32
|
+
sm: '280px',
|
|
33
|
+
md: '380px',
|
|
34
|
+
lg: '480px',
|
|
35
|
+
xl: '600px',
|
|
36
|
+
full: '100%',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ── Scroll lock ───────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
let scrollLockCount = 0;
|
|
42
|
+
function lockScroll() { if (++scrollLockCount === 1) document.body.style.overflow = 'hidden'; }
|
|
43
|
+
function unlockScroll() { if (--scrollLockCount <= 0) { scrollLockCount = 0; document.body.style.overflow = ''; } }
|
|
44
|
+
|
|
45
|
+
// ── Transform helpers ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function getClosedTransform(placement: DrawerPlacement): string {
|
|
48
|
+
switch (placement) {
|
|
49
|
+
case 'left': return 'translateX(-100%)';
|
|
50
|
+
case 'right': return 'translateX(100%)';
|
|
51
|
+
case 'top': return 'translateY(-100%)';
|
|
52
|
+
case 'bottom': return 'translateY(100%)';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getPanelPositionStyle(placement: DrawerPlacement, size: DrawerSize): React.CSSProperties {
|
|
57
|
+
const dim = SIZE_MAP[size];
|
|
58
|
+
const isHorizontal = placement === 'left' || placement === 'right';
|
|
59
|
+
|
|
60
|
+
const base: React.CSSProperties = { position: 'absolute' };
|
|
61
|
+
|
|
62
|
+
if (isHorizontal) {
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
top: 0,
|
|
66
|
+
bottom: 0,
|
|
67
|
+
[placement]: 0,
|
|
68
|
+
width: dim,
|
|
69
|
+
maxWidth: '100vw',
|
|
70
|
+
height: '100%',
|
|
71
|
+
};
|
|
72
|
+
} else {
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
left: 0,
|
|
76
|
+
right: 0,
|
|
77
|
+
[placement]: 0,
|
|
78
|
+
height: dim,
|
|
79
|
+
maxHeight: '100vh',
|
|
80
|
+
width: '100%',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Close button icon ─────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function CloseIcon() {
|
|
88
|
+
return (
|
|
89
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
90
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export function TkxDrawer({
|
|
98
|
+
isOpen,
|
|
99
|
+
onClose,
|
|
100
|
+
placement = 'right',
|
|
101
|
+
size = 'md',
|
|
102
|
+
title,
|
|
103
|
+
children,
|
|
104
|
+
footer,
|
|
105
|
+
closeOnOverlayClick = true,
|
|
106
|
+
closeOnEsc = true,
|
|
107
|
+
}: TkxDrawerProps) {
|
|
108
|
+
const theme = useTheme();
|
|
109
|
+
const reduced = useReducedMotion();
|
|
110
|
+
const titleId = useId();
|
|
111
|
+
const trapRef = useFocusTrap(isOpen);
|
|
112
|
+
|
|
113
|
+
useEscapeKey(onClose, closeOnEsc && isOpen);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (isOpen) lockScroll();
|
|
117
|
+
else unlockScroll();
|
|
118
|
+
return () => { if (isOpen) unlockScroll(); };
|
|
119
|
+
}, [isOpen]);
|
|
120
|
+
|
|
121
|
+
if (typeof document === 'undefined') return null;
|
|
122
|
+
|
|
123
|
+
const panelPositionStyle = getPanelPositionStyle(placement, size);
|
|
124
|
+
const closedTransform = getClosedTransform(placement);
|
|
125
|
+
const transition = reduced
|
|
126
|
+
? 'none'
|
|
127
|
+
: 'transform 280ms cubic-bezier(0.4,0,0.2,1), opacity 280ms ease';
|
|
128
|
+
|
|
129
|
+
const safeTitle = typeof title === 'string' ? sanitizeString(title) : title;
|
|
130
|
+
|
|
131
|
+
return createPortal(
|
|
132
|
+
<div
|
|
133
|
+
role="presentation"
|
|
134
|
+
className={tkx('fixed inset-0 z-[9000]')}
|
|
135
|
+
style={{
|
|
136
|
+
pointerEvents: isOpen ? 'auto' : 'none',
|
|
137
|
+
visibility: isOpen ? 'visible' : 'hidden',
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{/* Backdrop */}
|
|
141
|
+
<div
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
onClick={closeOnOverlayClick ? onClose : undefined}
|
|
144
|
+
className={tkx('absolute inset-0')}
|
|
145
|
+
style={{
|
|
146
|
+
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
147
|
+
backdropFilter: 'blur(3px)',
|
|
148
|
+
opacity: isOpen ? 1 : 0,
|
|
149
|
+
transition: reduced ? 'none' : 'opacity 280ms ease',
|
|
150
|
+
cursor: closeOnOverlayClick ? 'pointer' : 'default',
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
|
|
154
|
+
{/* Drawer panel */}
|
|
155
|
+
<div
|
|
156
|
+
ref={trapRef as React.RefObject<HTMLDivElement>}
|
|
157
|
+
role="dialog"
|
|
158
|
+
aria-modal="true"
|
|
159
|
+
aria-labelledby={title ? titleId : undefined}
|
|
160
|
+
className={tkx('absolute flex flex-col font-sans')}
|
|
161
|
+
style={{
|
|
162
|
+
...panelPositionStyle,
|
|
163
|
+
backgroundColor: theme.surface,
|
|
164
|
+
borderLeft: placement === 'right' ? `1px solid ${theme.border}` : undefined,
|
|
165
|
+
borderRight: placement === 'left' ? `1px solid ${theme.border}` : undefined,
|
|
166
|
+
borderTop: placement === 'bottom' ? `1px solid ${theme.border}` : undefined,
|
|
167
|
+
borderBottom: placement === 'top' ? `1px solid ${theme.border}` : undefined,
|
|
168
|
+
boxShadow: `0 20px 60px ${theme.bg}99`,
|
|
169
|
+
transform: isOpen ? 'translate(0,0)' : closedTransform,
|
|
170
|
+
opacity: isOpen ? 1 : 0,
|
|
171
|
+
transition,
|
|
172
|
+
willChange: 'transform',
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{/* Header */}
|
|
176
|
+
{(title !== undefined) && (
|
|
177
|
+
<div
|
|
178
|
+
className={tkx('flex items-center justify-between px-5 py-4 shrink-0')}
|
|
179
|
+
style={{ borderBottom: `1px solid ${theme.border}` }}
|
|
180
|
+
>
|
|
181
|
+
<h2
|
|
182
|
+
id={titleId}
|
|
183
|
+
className={tkx('m-0 text-base font-semibold leading-snug')}
|
|
184
|
+
style={{ color: theme.text }}
|
|
185
|
+
>
|
|
186
|
+
{safeTitle}
|
|
187
|
+
</h2>
|
|
188
|
+
<button
|
|
189
|
+
onClick={onClose}
|
|
190
|
+
aria-label="Close drawer"
|
|
191
|
+
className={tkx('bg-transparent border-none cursor-pointer rounded p-1 flex items-center justify-center focus-visible:focus-ring')}
|
|
192
|
+
style={{ color: theme.textMuted }}
|
|
193
|
+
>
|
|
194
|
+
<CloseIcon />
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{/* Close button when no title */}
|
|
200
|
+
{title === undefined && (
|
|
201
|
+
<div className={tkx('flex justify-end px-3 pt-3 shrink-0')}>
|
|
202
|
+
<button
|
|
203
|
+
onClick={onClose}
|
|
204
|
+
aria-label="Close drawer"
|
|
205
|
+
className={tkx('bg-transparent border-none cursor-pointer rounded p-1 flex items-center justify-center focus-visible:focus-ring')}
|
|
206
|
+
style={{ color: theme.textMuted }}
|
|
207
|
+
>
|
|
208
|
+
<CloseIcon />
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Body */}
|
|
214
|
+
<div
|
|
215
|
+
className={tkx('flex-1 overflow-y-auto px-5 py-4')}
|
|
216
|
+
style={{ color: theme.text }}
|
|
217
|
+
>
|
|
218
|
+
{children}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Footer */}
|
|
222
|
+
{footer && (
|
|
223
|
+
<div
|
|
224
|
+
className={tkx('flex items-center justify-end gap-2 px-5 py-4 shrink-0')}
|
|
225
|
+
style={{ borderTop: `1px solid ${theme.border}` }}
|
|
226
|
+
>
|
|
227
|
+
{footer}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>,
|
|
232
|
+
document.body,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
type DragEvent,
|
|
8
|
+
type ChangeEvent,
|
|
9
|
+
} from 'react';
|
|
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 interface TkxFileUploadProps {
|
|
16
|
+
accept?: string;
|
|
17
|
+
multiple?: boolean;
|
|
18
|
+
maxSize?: number;
|
|
19
|
+
maxFiles?: number;
|
|
20
|
+
onChange?: (files: File[]) => void;
|
|
21
|
+
onError?: (error: string) => void;
|
|
22
|
+
label?: string;
|
|
23
|
+
hint?: string;
|
|
24
|
+
isDisabled?: boolean;
|
|
25
|
+
preview?: boolean;
|
|
26
|
+
dragDrop?: boolean;
|
|
27
|
+
variant?: 'dropzone' | 'button';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface UploadedFile {
|
|
31
|
+
file: File;
|
|
32
|
+
id: string;
|
|
33
|
+
objectUrl?: string;
|
|
34
|
+
progress: number;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatSize(bytes: number): string {
|
|
39
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
40
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
41
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function FileTypeIcon({ mimeType, color }: { mimeType: string; color: string }) {
|
|
45
|
+
if (mimeType.startsWith('image/')) {
|
|
46
|
+
return (
|
|
47
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={1.5} aria-hidden="true">
|
|
48
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
49
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
50
|
+
<path d="M21 15l-5-5L5 21" />
|
|
51
|
+
</svg>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (mimeType === 'application/pdf') {
|
|
55
|
+
return (
|
|
56
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={1.5} aria-hidden="true">
|
|
57
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
58
|
+
<polyline points="14 2 14 8 20 8" />
|
|
59
|
+
<line x1="9" y1="13" x2="15" y2="13" />
|
|
60
|
+
<line x1="9" y1="17" x2="11" y2="17" />
|
|
61
|
+
</svg>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={1.5} aria-hidden="true">
|
|
66
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
67
|
+
<polyline points="14 2 14 8 20 8" />
|
|
68
|
+
</svg>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let idCounter = 0;
|
|
73
|
+
function uniqueId() { return `tkx-file-${++idCounter}`; }
|
|
74
|
+
|
|
75
|
+
export function TkxFileUpload({
|
|
76
|
+
accept,
|
|
77
|
+
multiple = false,
|
|
78
|
+
maxSize,
|
|
79
|
+
maxFiles,
|
|
80
|
+
onChange,
|
|
81
|
+
onError,
|
|
82
|
+
label = 'Upload files',
|
|
83
|
+
hint,
|
|
84
|
+
isDisabled = false,
|
|
85
|
+
preview = true,
|
|
86
|
+
dragDrop = true,
|
|
87
|
+
variant = 'dropzone',
|
|
88
|
+
}: TkxFileUploadProps) {
|
|
89
|
+
const theme = useTheme();
|
|
90
|
+
const reducedMotion = useReducedMotion();
|
|
91
|
+
const hintId = useId();
|
|
92
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
93
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
94
|
+
const [files, setFiles] = useState<UploadedFile[]>([]);
|
|
95
|
+
|
|
96
|
+
// Cleanup object URLs on unmount
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
return () => {
|
|
99
|
+
files.forEach((f) => { if (f.objectUrl) URL.revokeObjectURL(f.objectUrl); });
|
|
100
|
+
};
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const simulateProgress = useCallback((id: string) => {
|
|
105
|
+
let current = 0;
|
|
106
|
+
const interval = setInterval(() => {
|
|
107
|
+
current += Math.floor(Math.random() * 20) + 10;
|
|
108
|
+
if (current >= 100) {
|
|
109
|
+
current = 100;
|
|
110
|
+
clearInterval(interval);
|
|
111
|
+
}
|
|
112
|
+
setFiles((prev) =>
|
|
113
|
+
prev.map((f) => (f.id === id ? { ...f, progress: current } : f)),
|
|
114
|
+
);
|
|
115
|
+
}, 100);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const validateFile = useCallback((file: File): string | undefined => {
|
|
119
|
+
if (maxSize && file.size > maxSize) {
|
|
120
|
+
return `"${sanitizeString(file.name)}" exceeds max size of ${formatSize(maxSize)}`;
|
|
121
|
+
}
|
|
122
|
+
if (accept) {
|
|
123
|
+
const accepted = accept.split(',').map((s) => s.trim());
|
|
124
|
+
const matched = accepted.some((pattern) => {
|
|
125
|
+
if (pattern.startsWith('.')) return file.name.toLowerCase().endsWith(pattern.toLowerCase());
|
|
126
|
+
if (pattern.endsWith('/*')) return file.type.startsWith(pattern.slice(0, -1));
|
|
127
|
+
return file.type === pattern;
|
|
128
|
+
});
|
|
129
|
+
if (!matched) {
|
|
130
|
+
return `"${sanitizeString(file.name)}" is not an accepted file type`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}, [accept, maxSize]);
|
|
135
|
+
|
|
136
|
+
const processFiles = useCallback((incoming: File[]) => {
|
|
137
|
+
const available = maxFiles ? maxFiles - files.length : Infinity;
|
|
138
|
+
if (available <= 0) {
|
|
139
|
+
onError?.(`Maximum of ${maxFiles} file${maxFiles === 1 ? '' : 's'} allowed`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const toAdd = incoming.slice(0, available);
|
|
143
|
+
const newEntries: UploadedFile[] = toAdd.map((file) => {
|
|
144
|
+
const error = validateFile(file);
|
|
145
|
+
const objectUrl = preview && file.type.startsWith('image/') && !error
|
|
146
|
+
? URL.createObjectURL(file)
|
|
147
|
+
: undefined;
|
|
148
|
+
return { file, id: uniqueId(), objectUrl, progress: error ? 0 : 0, error };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
setFiles((prev) => {
|
|
152
|
+
const updated = multiple ? [...prev, ...newEntries] : newEntries;
|
|
153
|
+
const valid = updated.filter((f) => !f.error).map((f) => f.file);
|
|
154
|
+
onChange?.(valid);
|
|
155
|
+
return updated;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Report per-file errors
|
|
159
|
+
newEntries.forEach((entry) => {
|
|
160
|
+
if (entry.error) onError?.(entry.error);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Start progress simulation for valid files
|
|
164
|
+
newEntries.forEach((entry) => {
|
|
165
|
+
if (!entry.error) simulateProgress(entry.id);
|
|
166
|
+
});
|
|
167
|
+
}, [files.length, maxFiles, multiple, onChange, onError, preview, simulateProgress, validateFile]);
|
|
168
|
+
|
|
169
|
+
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
170
|
+
const selected = Array.from(e.target.files ?? []);
|
|
171
|
+
if (selected.length) processFiles(selected);
|
|
172
|
+
// Reset input so same file can be re-added after removal
|
|
173
|
+
e.target.value = '';
|
|
174
|
+
}, [processFiles]);
|
|
175
|
+
|
|
176
|
+
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
setIsDragOver(false);
|
|
179
|
+
if (isDisabled) return;
|
|
180
|
+
const dropped = Array.from(e.dataTransfer.files);
|
|
181
|
+
if (dropped.length) processFiles(dropped);
|
|
182
|
+
}, [isDisabled, processFiles]);
|
|
183
|
+
|
|
184
|
+
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
if (!isDisabled) setIsDragOver(true);
|
|
187
|
+
}, [isDisabled]);
|
|
188
|
+
|
|
189
|
+
const handleDragLeave = useCallback(() => setIsDragOver(false), []);
|
|
190
|
+
|
|
191
|
+
const removeFile = useCallback((id: string) => {
|
|
192
|
+
setFiles((prev) => {
|
|
193
|
+
const target = prev.find((f) => f.id === id);
|
|
194
|
+
if (target?.objectUrl) URL.revokeObjectURL(target.objectUrl);
|
|
195
|
+
const next = prev.filter((f) => f.id !== id);
|
|
196
|
+
onChange?.(next.filter((f) => !f.error).map((f) => f.file));
|
|
197
|
+
return next;
|
|
198
|
+
});
|
|
199
|
+
}, [onChange]);
|
|
200
|
+
|
|
201
|
+
const openPicker = useCallback(() => {
|
|
202
|
+
if (!isDisabled) inputRef.current?.click();
|
|
203
|
+
}, [isDisabled]);
|
|
204
|
+
|
|
205
|
+
const borderColor = isDragOver ? theme.primary : theme.border;
|
|
206
|
+
const safeLabel = sanitizeString(label);
|
|
207
|
+
const safeHint = hint ? sanitizeString(hint) : '';
|
|
208
|
+
|
|
209
|
+
const dropzoneContent = (
|
|
210
|
+
<div
|
|
211
|
+
role="button"
|
|
212
|
+
tabIndex={isDisabled ? -1 : 0}
|
|
213
|
+
aria-label={safeLabel}
|
|
214
|
+
aria-describedby={hint ? hintId : undefined}
|
|
215
|
+
aria-disabled={isDisabled}
|
|
216
|
+
onClick={openPicker}
|
|
217
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker(); } }}
|
|
218
|
+
onDrop={dragDrop ? handleDrop : undefined}
|
|
219
|
+
onDragOver={dragDrop ? handleDragOver : undefined}
|
|
220
|
+
onDragLeave={dragDrop ? handleDragLeave : undefined}
|
|
221
|
+
className={tkx('flex flex-col items-center justify-center gap-3 w-full p-8 rounded-lg')}
|
|
222
|
+
style={{
|
|
223
|
+
border: `2px dashed ${borderColor}`,
|
|
224
|
+
backgroundColor: isDragOver ? `${theme.primary}10` : theme.surface,
|
|
225
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
226
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
227
|
+
transition: reducedMotion ? 'none' : 'border-color 150ms ease, background-color 150ms ease',
|
|
228
|
+
outline: 'none',
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke={isDragOver ? theme.primary : theme.textMuted} strokeWidth={1.5} aria-hidden="true">
|
|
232
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
233
|
+
<polyline points="17 8 12 3 7 8" />
|
|
234
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
235
|
+
</svg>
|
|
236
|
+
<div className={tkx('text-center')}>
|
|
237
|
+
<p className={tkx('m-0 text-sm font-medium')} style={{ color: theme.text }}>
|
|
238
|
+
{isDragOver ? 'Drop files here' : safeLabel}
|
|
239
|
+
</p>
|
|
240
|
+
{safeHint && (
|
|
241
|
+
<p id={hintId} className={tkx('m-0 text-xs mt-1')} style={{ color: theme.textMuted }}>
|
|
242
|
+
{safeHint}
|
|
243
|
+
</p>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const buttonContent = (
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
disabled={isDisabled}
|
|
253
|
+
onClick={openPicker}
|
|
254
|
+
aria-describedby={hint ? hintId : undefined}
|
|
255
|
+
className={tkx('flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium font-sans border-none cursor-pointer')}
|
|
256
|
+
style={{
|
|
257
|
+
backgroundColor: theme.primary,
|
|
258
|
+
color: theme.bg,
|
|
259
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
260
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
264
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
265
|
+
<polyline points="17 8 12 3 7 8" />
|
|
266
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
267
|
+
</svg>
|
|
268
|
+
{safeLabel}
|
|
269
|
+
{hint && (
|
|
270
|
+
<span id={hintId} className={tkx('sr-only')}>{safeHint}</span>
|
|
271
|
+
)}
|
|
272
|
+
</button>
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div className={tkx('flex flex-col gap-3 w-full font-sans')}>
|
|
277
|
+
<input
|
|
278
|
+
ref={inputRef}
|
|
279
|
+
type="file"
|
|
280
|
+
accept={accept}
|
|
281
|
+
multiple={multiple}
|
|
282
|
+
disabled={isDisabled}
|
|
283
|
+
onChange={handleInputChange}
|
|
284
|
+
className={tkx('sr-only')}
|
|
285
|
+
tabIndex={-1}
|
|
286
|
+
aria-hidden="true"
|
|
287
|
+
/>
|
|
288
|
+
|
|
289
|
+
{variant === 'dropzone' ? dropzoneContent : buttonContent}
|
|
290
|
+
|
|
291
|
+
{files.length > 0 && (
|
|
292
|
+
<ul
|
|
293
|
+
role="status"
|
|
294
|
+
aria-live="polite"
|
|
295
|
+
aria-label="Uploaded files"
|
|
296
|
+
className={tkx('m-0 p-0 flex flex-col gap-2')}
|
|
297
|
+
style={{ listStyle: 'none' }}
|
|
298
|
+
>
|
|
299
|
+
{files.map((entry) => (
|
|
300
|
+
<li
|
|
301
|
+
key={entry.id}
|
|
302
|
+
className={tkx('flex flex-col gap-1 rounded-md p-3')}
|
|
303
|
+
style={{
|
|
304
|
+
backgroundColor: theme.surfaceAlt,
|
|
305
|
+
border: `1px solid ${entry.error ? theme.danger : theme.border}`,
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<div className={tkx('flex items-center gap-3')}>
|
|
309
|
+
{entry.objectUrl && preview ? (
|
|
310
|
+
<img
|
|
311
|
+
src={entry.objectUrl}
|
|
312
|
+
alt={sanitizeString(entry.file.name)}
|
|
313
|
+
className={tkx('rounded')}
|
|
314
|
+
style={{ width: 40, height: 40, objectFit: 'cover', flexShrink: 0 }}
|
|
315
|
+
/>
|
|
316
|
+
) : (
|
|
317
|
+
<span style={{ flexShrink: 0 }}>
|
|
318
|
+
<FileTypeIcon mimeType={entry.file.type} color={entry.error ? theme.danger : theme.textMuted} />
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
<div className={tkx('flex flex-col gap-0.5 flex-1 min-w-0')}>
|
|
322
|
+
<span
|
|
323
|
+
className={tkx('text-sm font-medium truncate')}
|
|
324
|
+
style={{ color: entry.error ? theme.danger : theme.text }}
|
|
325
|
+
title={entry.file.name}
|
|
326
|
+
>
|
|
327
|
+
{sanitizeString(entry.file.name)}
|
|
328
|
+
</span>
|
|
329
|
+
<span className={tkx('text-xs')} style={{ color: theme.textMuted }}>
|
|
330
|
+
{formatSize(entry.file.size)}
|
|
331
|
+
{entry.error && ` — ${entry.error}`}
|
|
332
|
+
</span>
|
|
333
|
+
</div>
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
aria-label={`Remove ${sanitizeString(entry.file.name)}`}
|
|
337
|
+
onClick={() => removeFile(entry.id)}
|
|
338
|
+
className={tkx('flex items-center justify-center bg-transparent border-none cursor-pointer rounded p-1')}
|
|
339
|
+
style={{ color: theme.textMuted, flexShrink: 0 }}
|
|
340
|
+
>
|
|
341
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
342
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
343
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
344
|
+
</svg>
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{!entry.error && entry.progress < 100 && (
|
|
349
|
+
<div
|
|
350
|
+
className={tkx('w-full overflow-hidden')}
|
|
351
|
+
style={{ height: '3px', borderRadius: '3px', backgroundColor: theme.border }}
|
|
352
|
+
aria-hidden="true"
|
|
353
|
+
>
|
|
354
|
+
<div
|
|
355
|
+
style={{
|
|
356
|
+
height: '100%',
|
|
357
|
+
borderRadius: '3px',
|
|
358
|
+
backgroundColor: theme.primary,
|
|
359
|
+
width: `${entry.progress}%`,
|
|
360
|
+
transition: reducedMotion ? 'none' : 'width 100ms ease',
|
|
361
|
+
}}
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</li>
|
|
366
|
+
))}
|
|
367
|
+
</ul>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|