@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,248 @@
1
+ import React, { forwardRef, useState, useEffect } from 'react';
2
+ import { Plus, Minus } from 'lucide-react';
3
+ import { cn } from '../utils/cn';
4
+
5
+ export interface NumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue' | 'size'> {
6
+ value?: number;
7
+ defaultValue?: number;
8
+ onChange?: (val: number) => void;
9
+ min?: number;
10
+ max?: number;
11
+ step?: number;
12
+ precision?: number;
13
+ size?: 'sm' | 'md' | 'lg';
14
+ stepperPosition?: 'right' | 'split';
15
+ }
16
+
17
+ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
18
+ (
19
+ {
20
+ value: controlledVal,
21
+ defaultValue = 0,
22
+ onChange,
23
+ min = -Infinity,
24
+ max = Infinity,
25
+ step = 1,
26
+ precision = 0,
27
+ size = 'md',
28
+ stepperPosition = 'right',
29
+ disabled = false,
30
+ className,
31
+ ...props
32
+ },
33
+ ref
34
+ ) => {
35
+ const [internalVal, setInternalVal] = useState<number>(defaultValue);
36
+ const value = controlledVal !== undefined ? controlledVal : internalVal;
37
+
38
+ const clamp = (num: number) => {
39
+ let clamped = Math.min(max, Math.max(min, num));
40
+ if (precision > 0) {
41
+ clamped = parseFloat(clamped.toFixed(precision));
42
+ }
43
+ return clamped;
44
+ };
45
+
46
+ const handleIncrement = () => {
47
+ if (disabled) return;
48
+ const next = clamp(value + step);
49
+ setInternalVal(next);
50
+ onChange?.(next);
51
+ };
52
+
53
+ const handleDecrement = () => {
54
+ if (disabled) return;
55
+ const next = clamp(value - step);
56
+ setInternalVal(next);
57
+ onChange?.(next);
58
+ };
59
+
60
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
61
+ const val = e.target.value;
62
+ if (val === '' || val === '-') {
63
+ return;
64
+ }
65
+ const parsed = parseFloat(val);
66
+ if (!isNaN(parsed)) {
67
+ const next = clamp(parsed);
68
+ setInternalVal(next);
69
+ onChange?.(next);
70
+ }
71
+ };
72
+
73
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
74
+ if (e.key === 'ArrowUp') {
75
+ e.preventDefault();
76
+ handleIncrement();
77
+ } else if (e.key === 'ArrowDown') {
78
+ e.preventDefault();
79
+ handleDecrement();
80
+ }
81
+ };
82
+
83
+ const heightClass = size === 'sm' ? 'h-8 text-xs' : size === 'lg' ? 'h-12 text-base' : 'h-10 text-sm';
84
+
85
+ if (stepperPosition === 'split') {
86
+ return (
87
+ <div className={cn('inline-flex items-center rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] overflow-hidden shadow-inner', className)}>
88
+ <button
89
+ type="button"
90
+ disabled={disabled || value <= min}
91
+ onClick={handleDecrement}
92
+ className="px-3 h-full flex items-center justify-center hover:bg-[var(--ui-bg-tertiary)] disabled:opacity-30 disabled:pointer-events-none text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors border-r border-[var(--ui-border-subtle)]"
93
+ >
94
+ <Minus className="w-3.5 h-3.5" />
95
+ </button>
96
+
97
+ <input
98
+ ref={ref}
99
+ type="number"
100
+ value={value}
101
+ disabled={disabled}
102
+ onChange={handleInputChange}
103
+ onKeyDown={handleKeyDown}
104
+ className={cn('w-20 text-center font-mono font-semibold bg-transparent text-[var(--ui-text-primary)] focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none', heightClass)}
105
+ {...props}
106
+ />
107
+
108
+ <button
109
+ type="button"
110
+ disabled={disabled || value >= max}
111
+ onClick={handleIncrement}
112
+ className="px-3 h-full flex items-center justify-center hover:bg-[var(--ui-bg-tertiary)] disabled:opacity-30 disabled:pointer-events-none text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors border-l border-[var(--ui-border-subtle)]"
113
+ >
114
+ <Plus className="w-3.5 h-3.5" />
115
+ </button>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <div className={cn('relative w-full inline-flex items-center', className)}>
122
+ <input
123
+ ref={ref}
124
+ type="number"
125
+ value={value}
126
+ disabled={disabled}
127
+ onChange={handleInputChange}
128
+ onKeyDown={handleKeyDown}
129
+ className={cn(
130
+ 'w-full 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 pr-10 font-mono font-semibold text-[var(--ui-text-primary)] transition-all shadow-inner focus:outline-none focus:ring-2 focus:ring-[var(--ui-primary)]/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
131
+ heightClass,
132
+ disabled && 'opacity-50 cursor-not-allowed'
133
+ )}
134
+ {...props}
135
+ />
136
+
137
+ <div className="absolute right-1 top-1 bottom-1 flex flex-col justify-center border-l border-[var(--ui-border-subtle)] pl-1 pr-1">
138
+ <button
139
+ type="button"
140
+ disabled={disabled || value >= max}
141
+ onClick={handleIncrement}
142
+ className="px-1.5 h-1/2 flex items-center justify-center hover:bg-[var(--ui-bg-tertiary)] rounded-t text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] disabled:opacity-30 transition-colors"
143
+ >
144
+ <Plus className="w-3 h-3" />
145
+ </button>
146
+ <button
147
+ type="button"
148
+ disabled={disabled || value <= min}
149
+ onClick={handleDecrement}
150
+ className="px-1.5 h-1/2 flex items-center justify-center hover:bg-[var(--ui-bg-tertiary)] rounded-b text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] disabled:opacity-30 transition-colors"
151
+ >
152
+ <Minus className="w-3 h-3" />
153
+ </button>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+ );
159
+ NumberInput.displayName = 'NumberInput';
160
+
161
+ /* -------------------------------------------------------------------------------------------------
162
+ * CurrencyInput Component
163
+ * -----------------------------------------------------------------------------------------------*/
164
+ export interface CurrencyInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> {
165
+ value?: number;
166
+ defaultValue?: number;
167
+ onChange?: (val: number) => void;
168
+ currency?: 'USD' | 'VND' | 'EUR' | string;
169
+ currencySymbol?: string;
170
+ position?: 'prefix' | 'suffix';
171
+ }
172
+
173
+ export function formatCurrencyString(val: number, separator = ','): string {
174
+ if (isNaN(val)) return '0';
175
+ const parts = val.toString().split('.');
176
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
177
+ return parts.join('.');
178
+ }
179
+
180
+ export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
181
+ (
182
+ {
183
+ value: controlledVal,
184
+ defaultValue = 0,
185
+ onChange,
186
+ currency = 'USD',
187
+ currencySymbol,
188
+ position = 'prefix',
189
+ disabled = false,
190
+ className,
191
+ ...props
192
+ },
193
+ ref
194
+ ) => {
195
+ const symbol = currencySymbol || (currency === 'VND' ? '₫' : currency === 'EUR' ? '€' : '$');
196
+ const isSuffix = position === 'suffix' || currency === 'VND';
197
+
198
+ const [internalVal, setInternalVal] = useState<number>(defaultValue);
199
+ const value = controlledVal !== undefined ? controlledVal : internalVal;
200
+ const [displayVal, setDisplayVal] = useState<string>(formatCurrencyString(value));
201
+
202
+ useEffect(() => {
203
+ setDisplayVal(formatCurrencyString(value));
204
+ }, [value]);
205
+
206
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
207
+ const raw = e.target.value.replace(/[^0-9.]/g, '');
208
+ const parsed = parseFloat(raw);
209
+ const num = isNaN(parsed) ? 0 : parsed;
210
+
211
+ setDisplayVal(raw ? formatCurrencyString(num) : '');
212
+ setInternalVal(num);
213
+ onChange?.(num);
214
+ };
215
+
216
+ return (
217
+ <div className={cn('relative w-full flex items-center', className)}>
218
+ {!isSuffix && (
219
+ <span className="absolute left-3.5 text-sm font-bold text-[var(--ui-text-muted)] pointer-events-none font-mono">
220
+ {symbol}
221
+ </span>
222
+ )}
223
+
224
+ <input
225
+ ref={ref}
226
+ type="text"
227
+ value={displayVal}
228
+ disabled={disabled}
229
+ onChange={handleInputChange}
230
+ className={cn(
231
+ '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 font-bold text-sm text-[var(--ui-text-primary)] transition-all shadow-inner focus:outline-none focus:ring-2 focus:ring-[var(--ui-primary)]/50',
232
+ !isSuffix && 'pl-8',
233
+ isSuffix && 'pr-12',
234
+ disabled && 'opacity-50 cursor-not-allowed'
235
+ )}
236
+ {...props}
237
+ />
238
+
239
+ {isSuffix && (
240
+ <span className="absolute right-3.5 text-xs font-bold text-[var(--ui-text-muted)] pointer-events-none font-mono uppercase">
241
+ {currency}
242
+ </span>
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+ );
248
+ CurrencyInput.displayName = 'CurrencyInput';
@@ -0,0 +1,35 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import { Eye, EyeOff } from 'lucide-react';
3
+ import { Input, InputProps } from './Input';
4
+
5
+ export interface PasswordInputProps extends Omit<InputProps, 'type' | 'suffixIcon'> {
6
+ showToggle?: boolean;
7
+ }
8
+
9
+ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
10
+ ({ showToggle = true, ...props }, ref) => {
11
+ const [showPassword, setShowPassword] = useState(false);
12
+
13
+ return (
14
+ <Input
15
+ ref={ref}
16
+ type={showPassword ? 'text' : 'password'}
17
+ suffixIcon={
18
+ showToggle ? (
19
+ <button
20
+ type="button"
21
+ onClick={() => setShowPassword(!showPassword)}
22
+ className="p-1 rounded hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] pointer-events-auto transition-colors"
23
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
24
+ >
25
+ {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
26
+ </button>
27
+ ) : undefined
28
+ }
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+ );
34
+
35
+ PasswordInput.displayName = 'PasswordInput';
@@ -0,0 +1,125 @@
1
+ import React, { forwardRef, useRef, useState, useEffect } from 'react';
2
+ import { cn } from '../utils/cn';
3
+
4
+ export interface PinInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
5
+ length?: number;
6
+ value?: string;
7
+ defaultValue?: string;
8
+ onChange?: (val: string) => void;
9
+ onComplete?: (val: string) => void;
10
+ mask?: boolean;
11
+ disabled?: boolean;
12
+ isInvalid?: boolean;
13
+ autoFocus?: boolean;
14
+ }
15
+
16
+ export const PinInput = forwardRef<HTMLDivElement, PinInputProps>(
17
+ (
18
+ {
19
+ className,
20
+ length = 4,
21
+ value: controlledValue,
22
+ defaultValue = '',
23
+ onChange,
24
+ onComplete,
25
+ mask = false,
26
+ disabled = false,
27
+ isInvalid = false,
28
+ autoFocus = false,
29
+ ...props
30
+ },
31
+ ref
32
+ ) => {
33
+ const [pins, setPins] = useState<string[]>(() => {
34
+ const initial = (controlledValue !== undefined ? controlledValue : defaultValue).slice(0, length);
35
+ return Array.from({ length }, (_, i) => initial[i] || '');
36
+ });
37
+
38
+ const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
39
+
40
+ useEffect(() => {
41
+ if (controlledValue !== undefined) {
42
+ setPins(Array.from({ length }, (_, i) => controlledValue[i] || ''));
43
+ }
44
+ }, [controlledValue, length]);
45
+
46
+ useEffect(() => {
47
+ if (autoFocus && inputsRef.current[0]) {
48
+ inputsRef.current[0].focus();
49
+ }
50
+ }, [autoFocus]);
51
+
52
+ const handleDigitChange = (index: number, val: string) => {
53
+ const sanitized = val.replace(/[^0-9a-zA-Z]/g, '').slice(-1);
54
+ const newPins = [...pins];
55
+ newPins[index] = sanitized;
56
+ setPins(newPins);
57
+
58
+ const combined = newPins.join('');
59
+ onChange?.(combined);
60
+
61
+ if (sanitized && index < length - 1) {
62
+ inputsRef.current[index + 1]?.focus();
63
+ }
64
+
65
+ if (combined.length === length && !newPins.includes('')) {
66
+ onComplete?.(combined);
67
+ }
68
+ };
69
+
70
+ const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
71
+ if (e.key === 'Backspace' && !pins[index] && index > 0) {
72
+ inputsRef.current[index - 1]?.focus();
73
+ }
74
+ };
75
+
76
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
77
+ e.preventDefault();
78
+ const pasted = e.clipboardData.getData('text').slice(0, length);
79
+ if (!pasted) return;
80
+
81
+ const newPins = Array.from({ length }, (_, i) => pasted[i] || '');
82
+ setPins(newPins);
83
+
84
+ const combined = newPins.join('');
85
+ onChange?.(combined);
86
+
87
+ const nextEmptyIndex = newPins.findIndex((p) => !p);
88
+ const focusIndex = nextEmptyIndex === -1 ? length - 1 : nextEmptyIndex;
89
+ inputsRef.current[focusIndex]?.focus();
90
+
91
+ if (combined.length === length && !newPins.includes('')) {
92
+ onComplete?.(combined);
93
+ }
94
+ };
95
+
96
+ return (
97
+ <div ref={ref} role="group" aria-label="PIN Code Input" className={cn('flex items-center gap-2', className)} {...props}>
98
+ {Array.from({ length }).map((_, index) => (
99
+ <input
100
+ key={index}
101
+ ref={(el) => {
102
+ inputsRef.current[index] = el;
103
+ }}
104
+ type={mask ? 'password' : 'text'}
105
+ inputMode="numeric"
106
+ maxLength={1}
107
+ disabled={disabled}
108
+ value={pins[index]}
109
+ onChange={(e) => handleDigitChange(index, e.target.value)}
110
+ onKeyDown={(e) => handleKeyDown(index, e)}
111
+ onPaste={handlePaste}
112
+ className={cn(
113
+ 'w-11 h-12 text-center text-lg font-bold font-mono rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-[var(--ui-text-primary)] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)] disabled:opacity-50 disabled:cursor-not-allowed',
114
+ isInvalid
115
+ ? 'border-[var(--ui-error)] focus-visible:ring-[var(--ui-error)]'
116
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
117
+ )}
118
+ />
119
+ ))}
120
+ </div>
121
+ );
122
+ }
123
+ );
124
+
125
+ PinInput.displayName = 'PinInput';
@@ -0,0 +1,142 @@
1
+ import React, { forwardRef, createContext, useContext } from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { cn } from '../utils/cn';
4
+
5
+ interface RadioGroupContextType {
6
+ name?: string;
7
+ value?: string;
8
+ onChange?: (val: string) => void;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ const RadioGroupContext = createContext<RadioGroupContextType | null>(null);
13
+
14
+ export const radioVariants = cva(
15
+ 'peer shrink-0 rounded-full border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)] disabled:cursor-not-allowed disabled:opacity-50 transition-all cursor-pointer flex items-center justify-center',
16
+ {
17
+ variants: {
18
+ size: {
19
+ sm: 'w-4 h-4',
20
+ md: 'w-5 h-5',
21
+ lg: 'w-6 h-6',
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ size: 'md',
26
+ },
27
+ }
28
+ );
29
+
30
+ export interface RadioProps
31
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
32
+ VariantProps<typeof radioVariants> {
33
+ label?: React.ReactNode;
34
+ value: string;
35
+ }
36
+
37
+ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
38
+ ({ className, size, label, value, checked, disabled, id, onChange, ...props }, ref) => {
39
+ const group = useContext(RadioGroupContext);
40
+ const isChecked = checked ?? (group?.value !== undefined ? group.value === value : false);
41
+ const isDisabled = disabled ?? group?.disabled ?? false;
42
+ const name = props.name || group?.name;
43
+
44
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
+ onChange?.(e);
46
+ if (group?.onChange) {
47
+ group.onChange(value);
48
+ }
49
+ };
50
+
51
+ return (
52
+ <label className={cn('inline-flex items-center gap-2 select-none cursor-pointer', isDisabled && 'cursor-not-allowed opacity-50')}>
53
+ <div className="relative flex items-center justify-center">
54
+ <input
55
+ ref={ref}
56
+ type="radio"
57
+ name={name}
58
+ value={value}
59
+ id={id}
60
+ disabled={isDisabled}
61
+ checked={isChecked}
62
+ onChange={handleChange}
63
+ className="sr-only peer"
64
+ {...props}
65
+ />
66
+ <div
67
+ className={cn(
68
+ radioVariants({ size }),
69
+ isChecked && 'border-[var(--ui-primary)]',
70
+ className
71
+ )}
72
+ >
73
+ {isChecked && (
74
+ <span className="w-2.5 h-2.5 rounded-full bg-[var(--ui-primary)] animate-in zoom-in-75 duration-150" />
75
+ )}
76
+ </div>
77
+ </div>
78
+ {label && <span className="text-sm font-medium text-[var(--ui-text-primary)]">{label}</span>}
79
+ </label>
80
+ );
81
+ }
82
+ );
83
+ Radio.displayName = 'Radio';
84
+
85
+ export interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
86
+ name?: string;
87
+ value?: string;
88
+ defaultValue?: string;
89
+ onValueChange?: (value: string) => void;
90
+ orientation?: 'horizontal' | 'vertical';
91
+ disabled?: boolean;
92
+ }
93
+
94
+ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
95
+ (
96
+ {
97
+ className,
98
+ name,
99
+ value: controlledValue,
100
+ defaultValue,
101
+ onValueChange,
102
+ orientation = 'vertical',
103
+ disabled,
104
+ children,
105
+ ...props
106
+ },
107
+ ref
108
+ ) => {
109
+ const [val, setVal] = React.useState(defaultValue || '');
110
+ const currentValue = controlledValue !== undefined ? controlledValue : val;
111
+
112
+ const handleChange = (newValue: string) => {
113
+ setVal(newValue);
114
+ onValueChange?.(newValue);
115
+ };
116
+
117
+ return (
118
+ <RadioGroupContext.Provider
119
+ value={{
120
+ name,
121
+ value: currentValue,
122
+ onChange: handleChange,
123
+ disabled,
124
+ }}
125
+ >
126
+ <div
127
+ ref={ref}
128
+ role="radiogroup"
129
+ className={cn(
130
+ 'flex gap-2',
131
+ orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap items-center',
132
+ className
133
+ )}
134
+ {...props}
135
+ >
136
+ {children}
137
+ </div>
138
+ </RadioGroupContext.Provider>
139
+ );
140
+ }
141
+ );
142
+ RadioGroup.displayName = 'RadioGroup';
@@ -0,0 +1,111 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import { cn } from '../utils/cn';
3
+
4
+ export interface RangeSliderProps {
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ value?: [number, number];
9
+ defaultValue?: [number, number];
10
+ onChange?: (val: [number, number]) => void;
11
+ showValue?: boolean;
12
+ formatValue?: (val: number) => string;
13
+ className?: string;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
18
+ (
19
+ {
20
+ min = 0,
21
+ max = 100,
22
+ step = 1,
23
+ value: controlledValue,
24
+ defaultValue = [20, 80],
25
+ onChange,
26
+ showValue = false,
27
+ formatValue = (v) => `${v}`,
28
+ className,
29
+ disabled = false,
30
+ },
31
+ ref
32
+ ) => {
33
+ const [stateValue, setStateValue] = useState<[number, number]>(defaultValue);
34
+ const [minVal, maxVal] = controlledValue || stateValue;
35
+
36
+ const minPercentage = Math.min(100, Math.max(0, ((minVal - min) / (max - min)) * 100));
37
+ const maxPercentage = Math.min(100, Math.max(0, ((maxVal - min) / (max - min)) * 100));
38
+
39
+ const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
40
+ const val = Math.min(Number(e.target.value), maxVal - step);
41
+ setStateValue([val, maxVal]);
42
+ onChange?.([val, maxVal]);
43
+ };
44
+
45
+ const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
46
+ const val = Math.max(Number(e.target.value), minVal + step);
47
+ setStateValue([minVal, val]);
48
+ onChange?.([minVal, val]);
49
+ };
50
+
51
+ return (
52
+ <div ref={ref} className={cn('w-full flex items-center gap-4', className)}>
53
+ <div className="relative w-full flex items-center h-6 select-none touch-none">
54
+ {/* Base rail */}
55
+ <div className="w-full h-2 rounded-full bg-[var(--ui-border-default)] overflow-hidden relative">
56
+ {/* Active Range Fill */}
57
+ <div
58
+ className="absolute h-full bg-[var(--ui-primary)]"
59
+ style={{
60
+ left: `${minPercentage}%`,
61
+ width: `${maxPercentage - minPercentage}%`,
62
+ }}
63
+ />
64
+ </div>
65
+
66
+ {/* Min thumb input */}
67
+ <input
68
+ type="range"
69
+ min={min}
70
+ max={max}
71
+ step={step}
72
+ value={minVal}
73
+ disabled={disabled}
74
+ onChange={handleMinChange}
75
+ className="absolute inset-0 w-full h-full opacity-0 pointer-events-auto cursor-pointer z-20"
76
+ />
77
+
78
+ {/* Max thumb input */}
79
+ <input
80
+ type="range"
81
+ min={min}
82
+ max={max}
83
+ step={step}
84
+ value={maxVal}
85
+ disabled={disabled}
86
+ onChange={handleMaxChange}
87
+ className="absolute inset-0 w-full h-full opacity-0 pointer-events-auto cursor-pointer z-30"
88
+ />
89
+
90
+ {/* Visual Thumbs */}
91
+ <div
92
+ className="absolute w-5 h-5 rounded-full bg-white border-2 border-[var(--ui-primary)] shadow-md pointer-events-none -translate-x-1/2 z-10"
93
+ style={{ left: `${minPercentage}%` }}
94
+ />
95
+ <div
96
+ className="absolute w-5 h-5 rounded-full bg-white border-2 border-[var(--ui-primary)] shadow-md pointer-events-none -translate-x-1/2 z-10"
97
+ style={{ left: `${maxPercentage}%` }}
98
+ />
99
+ </div>
100
+
101
+ {showValue && (
102
+ <span className="text-xs font-semibold font-mono text-[var(--ui-text-secondary)] min-w-[5rem] text-right">
103
+ {formatValue(minVal)} - {formatValue(maxVal)}
104
+ </span>
105
+ )}
106
+ </div>
107
+ );
108
+ }
109
+ );
110
+
111
+ RangeSlider.displayName = 'RangeSlider';
@@ -0,0 +1,34 @@
1
+ import { forwardRef } from 'react';
2
+ import { Search } from 'lucide-react';
3
+ import { Input, InputProps } from './Input';
4
+ import { Kbd } from '../typography/Kbd';
5
+
6
+ export interface SearchInputProps extends Omit<InputProps, 'type' | 'prefixIcon'> {
7
+ shortcut?: string;
8
+ }
9
+
10
+ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
11
+ ({ shortcut, clearable = true, suffixIcon, ...props }, ref) => {
12
+ return (
13
+ <Input
14
+ ref={ref}
15
+ type="search"
16
+ clearable={clearable}
17
+ prefixIcon={<Search className="w-4 h-4" />}
18
+ suffixIcon={
19
+ shortcut ? (
20
+ <div className="flex items-center gap-1">
21
+ <Kbd>{shortcut}</Kbd>
22
+ {suffixIcon}
23
+ </div>
24
+ ) : (
25
+ suffixIcon
26
+ )
27
+ }
28
+ {...props}
29
+ />
30
+ );
31
+ }
32
+ );
33
+
34
+ SearchInput.displayName = 'SearchInput';