@thangnqdev/ui 0.1.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 (253) hide show
  1. package/README.md +196 -0
  2. package/dist/Label-DWOlgSNW.d.cts +14 -0
  3. package/dist/Label-DWOlgSNW.d.ts +14 -0
  4. package/dist/bento/index.cjs +140 -0
  5. package/dist/bento/index.cjs.map +1 -0
  6. package/dist/bento/index.d.cts +4 -0
  7. package/dist/bento/index.d.ts +4 -0
  8. package/dist/bento/index.js +4 -0
  9. package/dist/bento/index.js.map +1 -0
  10. package/dist/bento/styles.css +46 -0
  11. package/dist/buttons/index.cjs +365 -0
  12. package/dist/buttons/index.cjs.map +1 -0
  13. package/dist/buttons/index.d.cts +4 -0
  14. package/dist/buttons/index.d.ts +4 -0
  15. package/dist/buttons/index.js +5 -0
  16. package/dist/buttons/index.js.map +1 -0
  17. package/dist/buttons/styles.css +6 -0
  18. package/dist/chunk-2OXZK6IP.js +514 -0
  19. package/dist/chunk-2OXZK6IP.js.map +1 -0
  20. package/dist/chunk-4S6L5V7X.js +15 -0
  21. package/dist/chunk-4S6L5V7X.js.map +1 -0
  22. package/dist/chunk-5FZ6ZFII.js +728 -0
  23. package/dist/chunk-5FZ6ZFII.js.map +1 -0
  24. package/dist/chunk-66VWUJEI.js +86 -0
  25. package/dist/chunk-66VWUJEI.js.map +1 -0
  26. package/dist/chunk-A7P3O5Z5.js +163 -0
  27. package/dist/chunk-A7P3O5Z5.js.map +1 -0
  28. package/dist/chunk-ATYGDTMK.js +257 -0
  29. package/dist/chunk-ATYGDTMK.js.map +1 -0
  30. package/dist/chunk-C4SUXMI3.js +138 -0
  31. package/dist/chunk-C4SUXMI3.js.map +1 -0
  32. package/dist/chunk-EU2LPUYP.js +25 -0
  33. package/dist/chunk-EU2LPUYP.js.map +1 -0
  34. package/dist/chunk-F5Q7BTMH.js +1991 -0
  35. package/dist/chunk-F5Q7BTMH.js.map +1 -0
  36. package/dist/chunk-GXI2H5XA.js +46 -0
  37. package/dist/chunk-GXI2H5XA.js.map +1 -0
  38. package/dist/chunk-H3LCNPMC.js +101 -0
  39. package/dist/chunk-H3LCNPMC.js.map +1 -0
  40. package/dist/chunk-IABPUCAQ.js +19 -0
  41. package/dist/chunk-IABPUCAQ.js.map +1 -0
  42. package/dist/chunk-KJRBRTQE.js +101 -0
  43. package/dist/chunk-KJRBRTQE.js.map +1 -0
  44. package/dist/chunk-LBUBXUYQ.js +40 -0
  45. package/dist/chunk-LBUBXUYQ.js.map +1 -0
  46. package/dist/chunk-LWFRUJRU.js +537 -0
  47. package/dist/chunk-LWFRUJRU.js.map +1 -0
  48. package/dist/chunk-NFVONJUK.js +882 -0
  49. package/dist/chunk-NFVONJUK.js.map +1 -0
  50. package/dist/chunk-PF4ZHGVW.js +284 -0
  51. package/dist/chunk-PF4ZHGVW.js.map +1 -0
  52. package/dist/chunk-WAFHC4DQ.js +366 -0
  53. package/dist/chunk-WAFHC4DQ.js.map +1 -0
  54. package/dist/chunk-X76KBH4Y.js +71 -0
  55. package/dist/chunk-X76KBH4Y.js.map +1 -0
  56. package/dist/core/index.cjs +103 -0
  57. package/dist/core/index.cjs.map +1 -0
  58. package/dist/core/index.d.cts +3 -0
  59. package/dist/core/index.d.ts +3 -0
  60. package/dist/core/index.js +4 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/core/styles.css +128 -0
  63. package/dist/data-display/index.cjs +1023 -0
  64. package/dist/data-display/index.cjs.map +1 -0
  65. package/dist/data-display/index.d.cts +4 -0
  66. package/dist/data-display/index.d.ts +4 -0
  67. package/dist/data-display/index.js +6 -0
  68. package/dist/data-display/index.js.map +1 -0
  69. package/dist/data-display/styles.css +4 -0
  70. package/dist/feedback/index.cjs +368 -0
  71. package/dist/feedback/index.cjs.map +1 -0
  72. package/dist/feedback/index.d.cts +4 -0
  73. package/dist/feedback/index.d.ts +4 -0
  74. package/dist/feedback/index.js +4 -0
  75. package/dist/feedback/index.js.map +1 -0
  76. package/dist/feedback/styles.css +4 -0
  77. package/dist/forms/index.cjs +2287 -0
  78. package/dist/forms/index.cjs.map +1 -0
  79. package/dist/forms/index.d.cts +5 -0
  80. package/dist/forms/index.d.ts +5 -0
  81. package/dist/forms/index.js +10 -0
  82. package/dist/forms/index.js.map +1 -0
  83. package/dist/forms/styles.css +6 -0
  84. package/dist/glass/index.cjs +165 -0
  85. package/dist/glass/index.cjs.map +1 -0
  86. package/dist/glass/index.d.cts +4 -0
  87. package/dist/glass/index.d.ts +4 -0
  88. package/dist/glass/index.js +4 -0
  89. package/dist/glass/index.js.map +1 -0
  90. package/dist/glass/styles.css +81 -0
  91. package/dist/index-BTvkbCIj.d.cts +146 -0
  92. package/dist/index-BTvkbCIj.d.ts +146 -0
  93. package/dist/index-BkRqGNG1.d.cts +201 -0
  94. package/dist/index-BkRqGNG1.d.ts +201 -0
  95. package/dist/index-BlPcGeW-.d.cts +45 -0
  96. package/dist/index-BlPcGeW-.d.ts +45 -0
  97. package/dist/index-BzOCqvC6.d.cts +32 -0
  98. package/dist/index-BzOCqvC6.d.ts +32 -0
  99. package/dist/index-CCeWYAvn.d.cts +83 -0
  100. package/dist/index-COQ7uwt9.d.cts +44 -0
  101. package/dist/index-COQ7uwt9.d.ts +44 -0
  102. package/dist/index-CenuF_P2.d.cts +221 -0
  103. package/dist/index-CenuF_P2.d.ts +221 -0
  104. package/dist/index-Cle6QzQl.d.ts +371 -0
  105. package/dist/index-D6fE7w-b.d.cts +90 -0
  106. package/dist/index-D6fE7w-b.d.ts +90 -0
  107. package/dist/index-DR8cfJUG.d.ts +83 -0
  108. package/dist/index-DzcWh1Hs.d.cts +131 -0
  109. package/dist/index-DzcWh1Hs.d.ts +131 -0
  110. package/dist/index-TyK-Ldpy.d.cts +371 -0
  111. package/dist/index-ugj5s6a3.d.cts +95 -0
  112. package/dist/index-ugj5s6a3.d.ts +95 -0
  113. package/dist/index.cjs +6400 -0
  114. package/dist/index.cjs.map +1 -0
  115. package/dist/index.d.cts +16 -0
  116. package/dist/index.d.ts +16 -0
  117. package/dist/index.js +21 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/layout/index.cjs +516 -0
  120. package/dist/layout/index.cjs.map +1 -0
  121. package/dist/layout/index.d.cts +4 -0
  122. package/dist/layout/index.d.ts +4 -0
  123. package/dist/layout/index.js +4 -0
  124. package/dist/layout/index.js.map +1 -0
  125. package/dist/layout/styles.css +8 -0
  126. package/dist/navigation/index.cjs +673 -0
  127. package/dist/navigation/index.cjs.map +1 -0
  128. package/dist/navigation/index.d.cts +2 -0
  129. package/dist/navigation/index.d.ts +2 -0
  130. package/dist/navigation/index.js +8 -0
  131. package/dist/navigation/index.js.map +1 -0
  132. package/dist/navigation/styles.css +4 -0
  133. package/dist/overlays/index.cjs +954 -0
  134. package/dist/overlays/index.cjs.map +1 -0
  135. package/dist/overlays/index.d.cts +4 -0
  136. package/dist/overlays/index.d.ts +4 -0
  137. package/dist/overlays/index.js +9 -0
  138. package/dist/overlays/index.js.map +1 -0
  139. package/dist/overlays/styles.css +8 -0
  140. package/dist/styles.css +333 -0
  141. package/dist/typography/index.cjs +311 -0
  142. package/dist/typography/index.cjs.map +1 -0
  143. package/dist/typography/index.d.cts +5 -0
  144. package/dist/typography/index.d.ts +5 -0
  145. package/dist/typography/index.js +6 -0
  146. package/dist/typography/index.js.map +1 -0
  147. package/dist/typography/styles.css +5 -0
  148. package/llms.txt +113 -0
  149. package/package.json +116 -0
  150. package/src/bento/BentoCard.tsx +140 -0
  151. package/src/bento/BentoGrid.tsx +50 -0
  152. package/src/bento/index.ts +2 -0
  153. package/src/bento/tokens.css +46 -0
  154. package/src/buttons/Button.tsx +104 -0
  155. package/src/buttons/ButtonGroup.tsx +43 -0
  156. package/src/buttons/IconButton.tsx +79 -0
  157. package/src/buttons/SplitButton.tsx +86 -0
  158. package/src/buttons/ToggleButton.tsx +157 -0
  159. package/src/buttons/index.ts +5 -0
  160. package/src/buttons/tokens.css +6 -0
  161. package/src/core/ThemeProvider.tsx +123 -0
  162. package/src/core/index.ts +2 -0
  163. package/src/core/tokens.css +128 -0
  164. package/src/data-display/Accordion.tsx +147 -0
  165. package/src/data-display/Avatar.tsx +134 -0
  166. package/src/data-display/Card.tsx +112 -0
  167. package/src/data-display/Carousel.tsx +130 -0
  168. package/src/data-display/Chip.tsx +74 -0
  169. package/src/data-display/DataTablePro.tsx +356 -0
  170. package/src/data-display/StatCard.tsx +60 -0
  171. package/src/data-display/Table.tsx +74 -0
  172. package/src/data-display/Timeline.tsx +62 -0
  173. package/src/data-display/TreeView.tsx +140 -0
  174. package/src/data-display/index.ts +10 -0
  175. package/src/data-display/tokens.css +4 -0
  176. package/src/feedback/Alert.tsx +80 -0
  177. package/src/feedback/EmptyState.tsx +45 -0
  178. package/src/feedback/ProgressBar.tsx +174 -0
  179. package/src/feedback/Skeleton.tsx +66 -0
  180. package/src/feedback/Spinner.tsx +65 -0
  181. package/src/feedback/index.ts +5 -0
  182. package/src/feedback/tokens.css +4 -0
  183. package/src/forms/Checkbox.tsx +69 -0
  184. package/src/forms/CheckboxGroup.tsx +29 -0
  185. package/src/forms/Combobox.tsx +368 -0
  186. package/src/forms/DatePicker.tsx +480 -0
  187. package/src/forms/FileUpload.tsx +222 -0
  188. package/src/forms/FormField.tsx +115 -0
  189. package/src/forms/Input.tsx +151 -0
  190. package/src/forms/MaskedInput.tsx +97 -0
  191. package/src/forms/NumberInput.tsx +248 -0
  192. package/src/forms/PasswordInput.tsx +35 -0
  193. package/src/forms/PinInput.tsx +125 -0
  194. package/src/forms/Radio.tsx +142 -0
  195. package/src/forms/RangeSlider.tsx +111 -0
  196. package/src/forms/SearchInput.tsx +34 -0
  197. package/src/forms/Select.tsx +103 -0
  198. package/src/forms/Slider.tsx +85 -0
  199. package/src/forms/Switch.tsx +95 -0
  200. package/src/forms/Textarea.tsx +114 -0
  201. package/src/forms/index.ts +18 -0
  202. package/src/forms/tokens.css +6 -0
  203. package/src/glass/GlassBadge.tsx +61 -0
  204. package/src/glass/GlassButton.tsx +59 -0
  205. package/src/glass/GlassCard.tsx +71 -0
  206. package/src/glass/index.ts +3 -0
  207. package/src/glass/tokens.css +81 -0
  208. package/src/index.ts +26 -0
  209. package/src/layout/AspectRatio.tsx +39 -0
  210. package/src/layout/Container.tsx +53 -0
  211. package/src/layout/Divider.tsx +61 -0
  212. package/src/layout/Flex.tsx +79 -0
  213. package/src/layout/Grid.tsx +124 -0
  214. package/src/layout/PageLayout.tsx +59 -0
  215. package/src/layout/ScrollArea.tsx +43 -0
  216. package/src/layout/Section.tsx +47 -0
  217. package/src/layout/Spacer.tsx +30 -0
  218. package/src/layout/Stack.tsx +88 -0
  219. package/src/layout/index.ts +10 -0
  220. package/src/layout/tokens.css +8 -0
  221. package/src/navigation/Breadcrumb.tsx +73 -0
  222. package/src/navigation/CommandPalette.tsx +142 -0
  223. package/src/navigation/DropdownMenu.tsx +160 -0
  224. package/src/navigation/Pagination.tsx +114 -0
  225. package/src/navigation/Stepper.tsx +93 -0
  226. package/src/navigation/Tabs.tsx +133 -0
  227. package/src/navigation/index.ts +6 -0
  228. package/src/navigation/tokens.css +4 -0
  229. package/src/overlays/AlertDialog.tsx +54 -0
  230. package/src/overlays/BottomSheet.tsx +123 -0
  231. package/src/overlays/ContextMenu.tsx +119 -0
  232. package/src/overlays/Drawer.tsx +145 -0
  233. package/src/overlays/FocusTrap.tsx +49 -0
  234. package/src/overlays/HoverCard.tsx +72 -0
  235. package/src/overlays/Modal.tsx +219 -0
  236. package/src/overlays/Popover.tsx +126 -0
  237. package/src/overlays/Portal.tsx +23 -0
  238. package/src/overlays/Toast.tsx +137 -0
  239. package/src/overlays/Tooltip.tsx +100 -0
  240. package/src/overlays/index.ts +11 -0
  241. package/src/overlays/tokens.css +8 -0
  242. package/src/styles/tokens.css +2 -0
  243. package/src/typography/Blockquote.tsx +32 -0
  244. package/src/typography/Code.tsx +89 -0
  245. package/src/typography/Heading.tsx +56 -0
  246. package/src/typography/Kbd.tsx +25 -0
  247. package/src/typography/Label.tsx +47 -0
  248. package/src/typography/Link.tsx +56 -0
  249. package/src/typography/Text.tsx +62 -0
  250. package/src/typography/index.ts +7 -0
  251. package/src/typography/tokens.css +5 -0
  252. package/src/utils/cn.ts +6 -0
  253. package/ui-contract.json +62 -0
@@ -0,0 +1,222 @@
1
+ import React, { forwardRef, useState, useRef } from 'react';
2
+ import { UploadCloud, File, FileText, Image as ImageIcon, X, CheckCircle2, AlertCircle } from 'lucide-react';
3
+ import { cn } from '../utils/cn';
4
+ import { ProgressBar } from '../feedback/ProgressBar';
5
+ import { Button } from '../buttons/Button';
6
+
7
+ export interface UploadedFile {
8
+ id: string;
9
+ file: File;
10
+ name: string;
11
+ size: number;
12
+ type: string;
13
+ progress?: number;
14
+ status?: 'pending' | 'uploading' | 'completed' | 'error';
15
+ previewUrl?: string;
16
+ errorMessage?: string;
17
+ }
18
+
19
+ export interface FileUploadProps {
20
+ accept?: string;
21
+ maxSizeMB?: number;
22
+ multiple?: boolean;
23
+ maxFiles?: number;
24
+ disabled?: boolean;
25
+ onFilesSelected?: (files: File[]) => void;
26
+ onFileRemove?: (fileId: string) => void;
27
+ className?: string;
28
+ label?: string;
29
+ description?: string;
30
+ }
31
+
32
+ export function formatFileSize(bytes: number): string {
33
+ if (bytes === 0) return '0 B';
34
+ const k = 1024;
35
+ const sizes = ['B', 'KB', 'MB', 'GB'];
36
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
37
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
38
+ }
39
+
40
+ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
41
+ (
42
+ {
43
+ accept,
44
+ maxSizeMB = 10,
45
+ multiple = true,
46
+ maxFiles = 5,
47
+ disabled = false,
48
+ onFilesSelected,
49
+ onFileRemove,
50
+ className,
51
+ label = 'Drag & drop files here, or browse',
52
+ description = 'Supports images, documents, and media up to 10MB',
53
+ },
54
+ ref
55
+ ) => {
56
+ const [isDragOver, setIsDragOver] = useState(false);
57
+ const [fileList, setFileList] = useState<UploadedFile[]>([]);
58
+ const inputRef = useRef<HTMLInputElement | null>(null);
59
+
60
+ const processFiles = (incoming: FileList | null) => {
61
+ if (!incoming) return;
62
+
63
+ const validFiles: File[] = [];
64
+ const newUploaded: UploadedFile[] = [];
65
+
66
+ Array.from(incoming).forEach((file) => {
67
+ if (fileList.length + newUploaded.length >= maxFiles) return;
68
+
69
+ const isOversized = file.size > maxSizeMB * 1024 * 1024;
70
+ const fileObj: UploadedFile = {
71
+ id: `${file.name}-${Date.now()}-${Math.random()}`,
72
+ file,
73
+ name: file.name,
74
+ size: file.size,
75
+ type: file.type,
76
+ progress: isOversized ? 0 : 100,
77
+ status: isOversized ? 'error' : 'completed',
78
+ errorMessage: isOversized ? `File exceeds ${maxSizeMB}MB limit` : undefined,
79
+ previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
80
+ };
81
+
82
+ newUploaded.push(fileObj);
83
+ if (!isOversized) validFiles.push(file);
84
+ });
85
+
86
+ setFileList((prev) => [...prev, ...newUploaded]);
87
+ if (validFiles.length > 0) {
88
+ onFilesSelected?.(validFiles);
89
+ }
90
+ };
91
+
92
+ const handleDragOver = (e: React.DragEvent) => {
93
+ e.preventDefault();
94
+ if (!disabled) setIsDragOver(true);
95
+ };
96
+
97
+ const handleDragLeave = (e: React.DragEvent) => {
98
+ e.preventDefault();
99
+ setIsDragOver(false);
100
+ };
101
+
102
+ const handleDrop = (e: React.DragEvent) => {
103
+ e.preventDefault();
104
+ setIsDragOver(false);
105
+ if (disabled) return;
106
+ processFiles(e.dataTransfer.files);
107
+ };
108
+
109
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
110
+ processFiles(e.target.files);
111
+ if (inputRef.current) inputRef.current.value = '';
112
+ };
113
+
114
+ const removeFile = (id: string) => {
115
+ setFileList((prev) => prev.filter((f) => f.id !== id));
116
+ onFileRemove?.(id);
117
+ };
118
+
119
+ return (
120
+ <div ref={ref} className={cn('w-full space-y-4', className)}>
121
+ {/* Dropzone Box */}
122
+ <div
123
+ onDragOver={handleDragOver}
124
+ onDragLeave={handleDragLeave}
125
+ onDrop={handleDrop}
126
+ onClick={() => !disabled && inputRef.current?.click()}
127
+ className={cn(
128
+ 'relative rounded-2xl p-6 sm:p-8 text-center border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-3 select-none',
129
+ disabled
130
+ ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)]/30'
131
+ : isDragOver
132
+ ? 'border-[var(--ui-primary)] bg-[var(--ui-primary)]/10 shadow-lg shadow-blue-500/20 scale-[0.99]'
133
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)]/60 hover:bg-[var(--ui-bg-secondary)]'
134
+ )}
135
+ >
136
+ <input
137
+ ref={inputRef}
138
+ type="file"
139
+ accept={accept}
140
+ multiple={multiple}
141
+ disabled={disabled}
142
+ onChange={handleFileChange}
143
+ className="hidden"
144
+ />
145
+
146
+ <div className="w-12 h-12 rounded-2xl bg-[var(--ui-primary)]/15 text-[var(--ui-primary)] flex items-center justify-center shadow-inner">
147
+ <UploadCloud className="w-6 h-6" />
148
+ </div>
149
+
150
+ <div className="space-y-1">
151
+ <h4 className="text-sm font-bold text-[var(--ui-text-primary)]">{label}</h4>
152
+ <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>
153
+ </div>
154
+
155
+ <Button size="xs" variant="outline" className="rounded-full px-4 mt-1 pointer-events-none">
156
+ Browse Files
157
+ </Button>
158
+ </div>
159
+
160
+ {/* Uploaded Files List */}
161
+ {fileList.length > 0 && (
162
+ <div className="space-y-2">
163
+ <span className="text-xs font-mono font-bold text-[var(--ui-text-muted)] uppercase tracking-wider">
164
+ Files ({fileList.length}/{maxFiles})
165
+ </span>
166
+
167
+ <div className="space-y-2">
168
+ {fileList.map((item) => (
169
+ <div
170
+ key={item.id}
171
+ className="p-3 rounded-2xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] flex items-center justify-between gap-3 shadow-xs animate-in fade-in duration-200"
172
+ >
173
+ <div className="flex items-center gap-3 truncate">
174
+ {item.previewUrl ? (
175
+ <img
176
+ src={item.previewUrl}
177
+ alt={item.name}
178
+ className="w-10 h-10 rounded-xl object-cover border border-[var(--ui-border-subtle)] shrink-0"
179
+ />
180
+ ) : (
181
+ <div className="w-10 h-10 rounded-xl bg-[var(--ui-bg-tertiary)] flex items-center justify-center text-[var(--ui-text-muted)] shrink-0">
182
+ {item.type.includes('pdf') ? <FileText className="w-5 h-5" /> : <File className="w-5 h-5" />}
183
+ </div>
184
+ )}
185
+
186
+ <div className="truncate space-y-0.5">
187
+ <span className="text-xs font-semibold text-[var(--ui-text-primary)] block truncate">
188
+ {item.name}
189
+ </span>
190
+ <div className="flex items-center gap-2 text-[11px] text-[var(--ui-text-muted)] font-mono">
191
+ <span>{formatFileSize(item.size)}</span>
192
+ {item.status === 'completed' && (
193
+ <span className="text-emerald-500 font-semibold flex items-center gap-0.5">
194
+ <CheckCircle2 className="w-3 h-3" /> Ready
195
+ </span>
196
+ )}
197
+ {item.status === 'error' && (
198
+ <span className="text-rose-500 font-semibold flex items-center gap-0.5">
199
+ <AlertCircle className="w-3 h-3" /> {item.errorMessage}
200
+ </span>
201
+ )}
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <button
207
+ type="button"
208
+ onClick={() => removeFile(item.id)}
209
+ className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-rose-500 transition-colors"
210
+ >
211
+ <X className="w-4 h-4" />
212
+ </button>
213
+ </div>
214
+ ))}
215
+ </div>
216
+ </div>
217
+ )}
218
+ </div>
219
+ );
220
+ }
221
+ );
222
+ FileUpload.displayName = 'FileUpload';
@@ -0,0 +1,115 @@
1
+ import React, { createContext, useContext, useId, forwardRef } from 'react';
2
+ import { cn } from '../utils/cn';
3
+ import { Label, LabelProps } from '../typography/Label';
4
+
5
+ interface FormFieldContextValue {
6
+ id: string;
7
+ labelId: string;
8
+ descriptionId: string;
9
+ errorId: string;
10
+ isRequired?: boolean;
11
+ isInvalid?: boolean;
12
+ isDisabled?: boolean;
13
+ }
14
+
15
+ const FormFieldContext = createContext<FormFieldContextValue | null>(null);
16
+
17
+ export function useFormField() {
18
+ const context = useContext(FormFieldContext);
19
+ return context;
20
+ }
21
+
22
+ export interface FormFieldProps extends React.HTMLAttributes<HTMLDivElement> {
23
+ isRequired?: boolean;
24
+ isInvalid?: boolean;
25
+ isDisabled?: boolean;
26
+ }
27
+
28
+ export const FormField = forwardRef<HTMLDivElement, FormFieldProps>(
29
+ ({ className, isRequired, isInvalid, isDisabled, children, ...props }, ref) => {
30
+ const generatedId = useId();
31
+ const id = props.id || generatedId;
32
+ const labelId = `${id}-label`;
33
+ const descriptionId = `${id}-description`;
34
+ const errorId = `${id}-error`;
35
+
36
+ return (
37
+ <FormFieldContext.Provider
38
+ value={{
39
+ id,
40
+ labelId,
41
+ descriptionId,
42
+ errorId,
43
+ isRequired,
44
+ isInvalid,
45
+ isDisabled,
46
+ }}
47
+ >
48
+ <div ref={ref} className={cn('space-y-1.5 w-full', className)} {...props}>
49
+ {children}
50
+ </div>
51
+ </FormFieldContext.Provider>
52
+ );
53
+ }
54
+ );
55
+ FormField.displayName = 'FormField';
56
+
57
+ export const FormLabel = forwardRef<HTMLLabelElement, LabelProps>(
58
+ ({ className, required, children, ...props }, ref) => {
59
+ const context = useFormField();
60
+ const isRequired = required ?? context?.isRequired;
61
+ const isError = context?.isInvalid;
62
+
63
+ return (
64
+ <Label
65
+ ref={ref}
66
+ id={context?.labelId}
67
+ htmlFor={context?.id}
68
+ required={isRequired}
69
+ error={isError}
70
+ className={className}
71
+ {...props}
72
+ >
73
+ {children}
74
+ </Label>
75
+ );
76
+ }
77
+ );
78
+ FormLabel.displayName = 'FormLabel';
79
+
80
+ export const FormHelperText = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
81
+ ({ className, children, ...props }, ref) => {
82
+ const context = useFormField();
83
+ return (
84
+ <p
85
+ ref={ref}
86
+ id={context?.descriptionId}
87
+ className={cn('text-xs text-[var(--ui-text-muted)]', className)}
88
+ {...props}
89
+ >
90
+ {children}
91
+ </p>
92
+ );
93
+ }
94
+ );
95
+ FormHelperText.displayName = 'FormHelperText';
96
+
97
+ export const FormErrorMessage = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
98
+ ({ className, children, ...props }, ref) => {
99
+ const context = useFormField();
100
+ if (!context?.isInvalid && !children) return null;
101
+
102
+ return (
103
+ <p
104
+ ref={ref}
105
+ id={context?.errorId}
106
+ role="alert"
107
+ className={cn('text-xs font-medium text-[var(--ui-error)]', className)}
108
+ {...props}
109
+ >
110
+ {children}
111
+ </p>
112
+ );
113
+ }
114
+ );
115
+ FormErrorMessage.displayName = 'FormErrorMessage';
@@ -0,0 +1,151 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { X } from 'lucide-react';
4
+ import { useFormField } from './FormField';
5
+ import { cn } from '../utils/cn';
6
+
7
+ export const inputVariants = cva(
8
+ 'w-full rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)]/70 backdrop-blur-md text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]/70 focus-visible:border-[var(--ui-primary)] focus-visible:shadow-[0_0_16px_rgba(99,102,241,0.2)] disabled:cursor-not-allowed disabled:opacity-50 shadow-inner',
9
+ {
10
+ variants: {
11
+ size: {
12
+ sm: 'h-8 px-2.5 text-xs',
13
+ md: 'h-10 px-3.5 text-sm',
14
+ lg: 'h-12 px-4 text-base',
15
+ },
16
+ variant: {
17
+ default: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]',
18
+ filled: 'bg-[var(--ui-bg-secondary)] border-transparent hover:bg-[var(--ui-bg-tertiary)]',
19
+ },
20
+ isInvalid: {
21
+ true: 'border-[var(--ui-error)] focus-visible:ring-[var(--ui-error)]/70 focus-visible:shadow-[0_0_16px_rgba(244,63,94,0.25)]',
22
+ false: '',
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ size: 'md',
27
+ variant: 'default',
28
+ isInvalid: false,
29
+ },
30
+ }
31
+ );
32
+
33
+ export interface InputProps
34
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
35
+ VariantProps<typeof inputVariants> {
36
+ prefixIcon?: React.ReactNode;
37
+ suffixIcon?: React.ReactNode;
38
+ clearable?: boolean;
39
+ onClear?: () => void;
40
+ }
41
+
42
+ export const Input = forwardRef<HTMLInputElement, InputProps>(
43
+ (
44
+ {
45
+ className,
46
+ type = 'text',
47
+ size,
48
+ variant,
49
+ isInvalid: customInvalid,
50
+ prefixIcon,
51
+ suffixIcon,
52
+ clearable,
53
+ onClear,
54
+ value,
55
+ onChange,
56
+ disabled,
57
+ id,
58
+ ...props
59
+ },
60
+ ref
61
+ ) => {
62
+ const formField = useFormField();
63
+ const isInvalid = customInvalid ?? formField?.isInvalid ?? false;
64
+ const isDisabled = disabled ?? formField?.isDisabled ?? false;
65
+ const inputId = id || formField?.id;
66
+
67
+ const isControlled = value !== undefined;
68
+ const [uncontrolledVal, setUncontrolledVal] = useState(
69
+ props.defaultValue !== undefined ? String(props.defaultValue) : ''
70
+ );
71
+ const innerRef = React.useRef<HTMLInputElement | null>(null);
72
+
73
+ const handleRef = (node: HTMLInputElement | null) => {
74
+ innerRef.current = node;
75
+ if (typeof ref === 'function') {
76
+ ref(node);
77
+ } else if (ref) {
78
+ (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
79
+ }
80
+ };
81
+
82
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
83
+ if (!isControlled) {
84
+ setUncontrolledVal(e.target.value);
85
+ }
86
+ onChange?.(e);
87
+ };
88
+
89
+ const handleClear = () => {
90
+ if (!isControlled && innerRef.current) {
91
+ innerRef.current.value = '';
92
+ setUncontrolledVal('');
93
+ const event = new Event('input', { bubbles: true });
94
+ innerRef.current.dispatchEvent(event);
95
+ }
96
+ onClear?.();
97
+ };
98
+
99
+ const hasValue = isControlled
100
+ ? Boolean(value)
101
+ : Boolean(uncontrolledVal || innerRef.current?.value);
102
+
103
+ return (
104
+ <div className="relative w-full flex items-center">
105
+ {prefixIcon && (
106
+ <div className="absolute left-3 flex items-center pointer-events-none text-[var(--ui-text-muted)]">
107
+ {prefixIcon}
108
+ </div>
109
+ )}
110
+
111
+ <input
112
+ ref={handleRef}
113
+ id={inputId}
114
+ type={type}
115
+ disabled={isDisabled}
116
+ {...(isControlled ? { value } : {})}
117
+ onChange={handleChange}
118
+ aria-invalid={isInvalid}
119
+ aria-describedby={
120
+ isInvalid
121
+ ? formField?.errorId
122
+ : formField?.descriptionId || undefined
123
+ }
124
+ className={cn(
125
+ inputVariants({ size, variant, isInvalid }),
126
+ prefixIcon && 'pl-9',
127
+ (suffixIcon || (clearable && hasValue)) && 'pr-9',
128
+ className
129
+ )}
130
+ {...props}
131
+ />
132
+
133
+ <div className="absolute right-3 flex items-center gap-1 text-[var(--ui-text-muted)]">
134
+ {clearable && hasValue && !isDisabled && (
135
+ <button
136
+ type="button"
137
+ onClick={handleClear}
138
+ className="p-0.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
139
+ aria-label="Clear input"
140
+ >
141
+ <X className="w-3.5 h-3.5" />
142
+ </button>
143
+ )}
144
+ {suffixIcon && <span className="pointer-events-none flex items-center">{suffixIcon}</span>}
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+ );
150
+
151
+ Input.displayName = 'Input';
@@ -0,0 +1,97 @@
1
+ import React, { forwardRef, useState, useEffect } from 'react';
2
+ import { cn } from '../utils/cn';
3
+
4
+ export interface MaskedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> {
5
+ mask: string; // e.g. '(###) ###-####' or '#### #### #### ####'
6
+ maskChar?: string; // default '#'
7
+ value?: string;
8
+ defaultValue?: string;
9
+ onValueChange?: (unmasked: string, formatted: string) => void;
10
+ prefixIcon?: React.ReactNode;
11
+ suffixIcon?: React.ReactNode;
12
+ }
13
+
14
+ export function applyMask(rawVal: string, mask: string, maskChar = '#'): { formatted: string; unmasked: string } {
15
+ const digitsOnly = rawVal.replace(/\D/g, '');
16
+ if (!digitsOnly) return { formatted: '', unmasked: '' };
17
+ let formatted = '';
18
+ let digitIndex = 0;
19
+
20
+ for (let i = 0; i < mask.length; i++) {
21
+ const char = mask[i];
22
+ if (char === maskChar) {
23
+ if (digitIndex < digitsOnly.length) {
24
+ formatted += digitsOnly[digitIndex++];
25
+ } else {
26
+ break;
27
+ }
28
+ } else {
29
+ if (digitIndex < digitsOnly.length) {
30
+ formatted += char;
31
+ }
32
+ }
33
+ }
34
+
35
+ return { formatted, unmasked: digitsOnly };
36
+ }
37
+
38
+ export const MaskedInput = forwardRef<HTMLInputElement, MaskedInputProps>(
39
+ (
40
+ {
41
+ mask,
42
+ maskChar = '#',
43
+ value: controlledVal,
44
+ defaultValue = '',
45
+ onValueChange,
46
+ prefixIcon,
47
+ suffixIcon,
48
+ disabled = false,
49
+ className,
50
+ ...props
51
+ },
52
+ ref
53
+ ) => {
54
+ const [internalVal, setInternalVal] = useState<string>(
55
+ applyMask(defaultValue, mask, maskChar).formatted
56
+ );
57
+ const displayValue = controlledVal !== undefined ? applyMask(controlledVal, mask, maskChar).formatted : internalVal;
58
+
59
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
60
+ const { formatted, unmasked } = applyMask(e.target.value, mask, maskChar);
61
+ setInternalVal(formatted);
62
+ onValueChange?.(unmasked, formatted);
63
+ };
64
+
65
+ return (
66
+ <div className={cn('relative w-full flex items-center', className)}>
67
+ {prefixIcon && (
68
+ <div className="absolute left-3.5 flex items-center pointer-events-none text-[var(--ui-text-muted)]">
69
+ {prefixIcon}
70
+ </div>
71
+ )}
72
+
73
+ <input
74
+ ref={ref}
75
+ type="text"
76
+ value={displayValue}
77
+ disabled={disabled}
78
+ onChange={handleInputChange}
79
+ className={cn(
80
+ 'w-full h-10 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] px-3.5 font-mono text-sm text-[var(--ui-text-primary)] transition-all shadow-inner focus:outline-none focus:ring-2 focus:ring-[var(--ui-primary)]/50',
81
+ prefixIcon && 'pl-9',
82
+ suffixIcon && 'pr-9',
83
+ disabled && 'opacity-50 cursor-not-allowed'
84
+ )}
85
+ {...props}
86
+ />
87
+
88
+ {suffixIcon && (
89
+ <div className="absolute right-3.5 flex items-center pointer-events-none text-[var(--ui-text-muted)]">
90
+ {suffixIcon}
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ }
96
+ );
97
+ MaskedInput.displayName = 'MaskedInput';