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.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/index.cjs +70 -0
  4. package/dist/index.d.ts +89 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11002 -0
  7. package/dist/src/a11y/index.d.ts +24 -0
  8. package/dist/src/a11y/index.d.ts.map +1 -0
  9. package/dist/src/components/TkxAccordion.d.ts +32 -0
  10. package/dist/src/components/TkxAccordion.d.ts.map +1 -0
  11. package/dist/src/components/TkxAlert.d.ts +14 -0
  12. package/dist/src/components/TkxAlert.d.ts.map +1 -0
  13. package/dist/src/components/TkxAvatar.d.ts +14 -0
  14. package/dist/src/components/TkxAvatar.d.ts.map +1 -0
  15. package/dist/src/components/TkxBadge.d.ts +12 -0
  16. package/dist/src/components/TkxBadge.d.ts.map +1 -0
  17. package/dist/src/components/TkxButton.d.ts +17 -0
  18. package/dist/src/components/TkxButton.d.ts.map +1 -0
  19. package/dist/src/components/TkxCard.d.ts +20 -0
  20. package/dist/src/components/TkxCard.d.ts.map +1 -0
  21. package/dist/src/components/TkxCarousel.d.ts +32 -0
  22. package/dist/src/components/TkxCarousel.d.ts.map +1 -0
  23. package/dist/src/components/TkxChat.d.ts +32 -0
  24. package/dist/src/components/TkxChat.d.ts.map +1 -0
  25. package/dist/src/components/TkxCheckbox.d.ts +13 -0
  26. package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
  27. package/dist/src/components/TkxClock.d.ts +23 -0
  28. package/dist/src/components/TkxClock.d.ts.map +1 -0
  29. package/dist/src/components/TkxColorPicker.d.ts +18 -0
  30. package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
  31. package/dist/src/components/TkxCommand.d.ts +34 -0
  32. package/dist/src/components/TkxCommand.d.ts.map +1 -0
  33. package/dist/src/components/TkxDatePicker.d.ts +47 -0
  34. package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
  35. package/dist/src/components/TkxDivider.d.ts +8 -0
  36. package/dist/src/components/TkxDivider.d.ts.map +1 -0
  37. package/dist/src/components/TkxDrawer.d.ts +16 -0
  38. package/dist/src/components/TkxDrawer.d.ts.map +1 -0
  39. package/dist/src/components/TkxFileUpload.d.ts +16 -0
  40. package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
  41. package/dist/src/components/TkxIcon.d.ts +16 -0
  42. package/dist/src/components/TkxIcon.d.ts.map +1 -0
  43. package/dist/src/components/TkxImage.d.ts +16 -0
  44. package/dist/src/components/TkxImage.d.ts.map +1 -0
  45. package/dist/src/components/TkxInput.d.ts +13 -0
  46. package/dist/src/components/TkxInput.d.ts.map +1 -0
  47. package/dist/src/components/TkxLogo.d.ts +21 -0
  48. package/dist/src/components/TkxLogo.d.ts.map +1 -0
  49. package/dist/src/components/TkxMenu.d.ts +64 -0
  50. package/dist/src/components/TkxMenu.d.ts.map +1 -0
  51. package/dist/src/components/TkxModal.d.ts +14 -0
  52. package/dist/src/components/TkxModal.d.ts.map +1 -0
  53. package/dist/src/components/TkxNumberInput.d.ts +32 -0
  54. package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
  55. package/dist/src/components/TkxOTP.d.ts +24 -0
  56. package/dist/src/components/TkxOTP.d.ts.map +1 -0
  57. package/dist/src/components/TkxPagination.d.ts +19 -0
  58. package/dist/src/components/TkxPagination.d.ts.map +1 -0
  59. package/dist/src/components/TkxProgress.d.ts +13 -0
  60. package/dist/src/components/TkxProgress.d.ts.map +1 -0
  61. package/dist/src/components/TkxRadio.d.ts +22 -0
  62. package/dist/src/components/TkxRadio.d.ts.map +1 -0
  63. package/dist/src/components/TkxRating.d.ts +18 -0
  64. package/dist/src/components/TkxRating.d.ts.map +1 -0
  65. package/dist/src/components/TkxSelect.d.ts +37 -0
  66. package/dist/src/components/TkxSelect.d.ts.map +1 -0
  67. package/dist/src/components/TkxSkeleton.d.ts +12 -0
  68. package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
  69. package/dist/src/components/TkxSlider.d.ts +33 -0
  70. package/dist/src/components/TkxSlider.d.ts.map +1 -0
  71. package/dist/src/components/TkxStepper.d.ts +31 -0
  72. package/dist/src/components/TkxStepper.d.ts.map +1 -0
  73. package/dist/src/components/TkxTable.d.ts +23 -0
  74. package/dist/src/components/TkxTable.d.ts.map +1 -0
  75. package/dist/src/components/TkxTabs.d.ts +32 -0
  76. package/dist/src/components/TkxTabs.d.ts.map +1 -0
  77. package/dist/src/components/TkxTag.d.ts +30 -0
  78. package/dist/src/components/TkxTag.d.ts.map +1 -0
  79. package/dist/src/components/TkxTimeline.d.ts +20 -0
  80. package/dist/src/components/TkxTimeline.d.ts.map +1 -0
  81. package/dist/src/components/TkxToast.d.ts +23 -0
  82. package/dist/src/components/TkxToast.d.ts.map +1 -0
  83. package/dist/src/components/TkxToggle.d.ts +11 -0
  84. package/dist/src/components/TkxToggle.d.ts.map +1 -0
  85. package/dist/src/components/TkxTooltip.d.ts +10 -0
  86. package/dist/src/components/TkxTooltip.d.ts.map +1 -0
  87. package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
  88. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
  89. package/dist/src/components/index.d.ts +41 -0
  90. package/dist/src/components/index.d.ts.map +1 -0
  91. package/dist/src/engine/css.d.ts +24 -0
  92. package/dist/src/engine/css.d.ts.map +1 -0
  93. package/dist/src/engine/index.d.ts +10 -0
  94. package/dist/src/engine/index.d.ts.map +1 -0
  95. package/dist/src/engine/quantum.d.ts +47 -0
  96. package/dist/src/engine/quantum.d.ts.map +1 -0
  97. package/dist/src/engine/security.d.ts +54 -0
  98. package/dist/src/engine/security.d.ts.map +1 -0
  99. package/dist/src/engine/tkx.d.ts +29 -0
  100. package/dist/src/engine/tkx.d.ts.map +1 -0
  101. package/dist/src/engine/wcag.d.ts +34 -0
  102. package/dist/src/engine/wcag.d.ts.map +1 -0
  103. package/dist/src/hooks/index.d.ts +8 -0
  104. package/dist/src/hooks/index.d.ts.map +1 -0
  105. package/dist/src/themes/index.d.ts +26 -0
  106. package/dist/src/themes/index.d.ts.map +1 -0
  107. package/dist/style.css +1 -0
  108. package/dist/tests/TkxButton.test.d.ts +2 -0
  109. package/dist/tests/TkxButton.test.d.ts.map +1 -0
  110. package/dist/tests/a11y/components.test.d.ts +2 -0
  111. package/dist/tests/a11y/components.test.d.ts.map +1 -0
  112. package/dist/tests/quantum.test.d.ts +2 -0
  113. package/dist/tests/quantum.test.d.ts.map +1 -0
  114. package/dist/tests/security.test.d.ts +2 -0
  115. package/dist/tests/security.test.d.ts.map +1 -0
  116. package/dist/tests/setup.d.ts +2 -0
  117. package/dist/tests/setup.d.ts.map +1 -0
  118. package/dist/tests/tkx.test.d.ts +2 -0
  119. package/dist/tests/tkx.test.d.ts.map +1 -0
  120. package/dist/tests/wcag.test.d.ts +2 -0
  121. package/dist/tests/wcag.test.d.ts.map +1 -0
  122. package/package.json +78 -0
  123. package/src/a11y/index.tsx +87 -0
  124. package/src/components/TkxAccordion.tsx +588 -0
  125. package/src/components/TkxAlert.tsx +91 -0
  126. package/src/components/TkxAvatar.tsx +101 -0
  127. package/src/components/TkxBadge.tsx +73 -0
  128. package/src/components/TkxButton.tsx +153 -0
  129. package/src/components/TkxCard.tsx +92 -0
  130. package/src/components/TkxCarousel.tsx +552 -0
  131. package/src/components/TkxChat.tsx +354 -0
  132. package/src/components/TkxCheckbox.tsx +218 -0
  133. package/src/components/TkxClock.tsx +601 -0
  134. package/src/components/TkxColorPicker.tsx +633 -0
  135. package/src/components/TkxCommand.tsx +644 -0
  136. package/src/components/TkxDatePicker.tsx +1567 -0
  137. package/src/components/TkxDivider.tsx +49 -0
  138. package/src/components/TkxDrawer.tsx +234 -0
  139. package/src/components/TkxFileUpload.tsx +371 -0
  140. package/src/components/TkxIcon.tsx +683 -0
  141. package/src/components/TkxImage.tsx +277 -0
  142. package/src/components/TkxInput.tsx +101 -0
  143. package/src/components/TkxLogo.tsx +297 -0
  144. package/src/components/TkxMenu.tsx +1042 -0
  145. package/src/components/TkxModal.tsx +119 -0
  146. package/src/components/TkxNumberInput.tsx +408 -0
  147. package/src/components/TkxOTP.tsx +295 -0
  148. package/src/components/TkxPagination.tsx +388 -0
  149. package/src/components/TkxProgress.tsx +114 -0
  150. package/src/components/TkxRadio.tsx +284 -0
  151. package/src/components/TkxRating.tsx +286 -0
  152. package/src/components/TkxSelect.tsx +923 -0
  153. package/src/components/TkxSkeleton.tsx +105 -0
  154. package/src/components/TkxSlider.tsx +492 -0
  155. package/src/components/TkxStepper.tsx +485 -0
  156. package/src/components/TkxTable.tsx +148 -0
  157. package/src/components/TkxTabs.tsx +124 -0
  158. package/src/components/TkxTag.tsx +319 -0
  159. package/src/components/TkxTimeline.tsx +303 -0
  160. package/src/components/TkxToast.tsx +284 -0
  161. package/src/components/TkxToggle.tsx +93 -0
  162. package/src/components/TkxTooltip.tsx +69 -0
  163. package/src/components/TkxVideoPlayer.tsx +1013 -0
  164. package/src/components/index.ts +40 -0
  165. package/src/engine/css.ts +126 -0
  166. package/src/engine/index.ts +10 -0
  167. package/src/engine/quantum.ts +207 -0
  168. package/src/engine/security.ts +217 -0
  169. package/src/engine/tkx.ts +874 -0
  170. package/src/engine/wcag.ts +275 -0
  171. package/src/hooks/index.ts +120 -0
  172. package/src/styles/global.css +167 -0
  173. 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
+ }