@thangnqdev/ui 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/bento/styles.css +2 -46
  7. package/dist/buttons/index.cjs +111 -18
  8. package/dist/buttons/index.cjs.map +1 -1
  9. package/dist/buttons/index.d.cts +1 -1
  10. package/dist/buttons/index.d.ts +1 -1
  11. package/dist/buttons/index.js +3 -3
  12. package/dist/buttons/styles.css +2 -6
  13. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  14. package/dist/chunk-2YJBW4E2.js.map +1 -0
  15. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  16. package/dist/chunk-4CRFNALF.js.map +1 -0
  17. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  18. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  19. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  20. package/dist/chunk-77KTYOMQ.js.map +1 -0
  21. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  22. package/dist/chunk-ADO3SVUA.js.map +1 -0
  23. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  24. package/dist/chunk-D7FB3TCA.js.map +1 -0
  25. package/dist/chunk-DSEL7ELZ.js +314 -0
  26. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  27. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  28. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  29. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  30. package/dist/chunk-GXWB6BSY.js.map +1 -0
  31. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  32. package/dist/chunk-I2FQBMVW.js.map +1 -0
  33. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  34. package/dist/chunk-I5O2WCGF.js.map +1 -0
  35. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  36. package/dist/chunk-ME4XILG5.js.map +1 -0
  37. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  38. package/dist/chunk-PC5SZFZC.js.map +1 -0
  39. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  40. package/dist/chunk-S6XJMYME.js.map +1 -0
  41. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  42. package/dist/chunk-V52JOSDF.js.map +1 -0
  43. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  44. package/dist/chunk-W5WNWTQC.js.map +1 -0
  45. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  46. package/dist/chunk-YJ64RI7J.js.map +1 -0
  47. package/dist/chunk-YOCGBORV.js +155 -0
  48. package/dist/chunk-YOCGBORV.js.map +1 -0
  49. package/dist/chunk-ZEDXARXW.js +96 -0
  50. package/dist/chunk-ZEDXARXW.js.map +1 -0
  51. package/dist/components.css +2 -0
  52. package/dist/core/index.cjs +33 -24
  53. package/dist/core/index.cjs.map +1 -1
  54. package/dist/core/index.js +2 -2
  55. package/dist/core/styles.css +2 -134
  56. package/dist/data-display/index.cjs +770 -247
  57. package/dist/data-display/index.cjs.map +1 -1
  58. package/dist/data-display/index.d.cts +1 -1
  59. package/dist/data-display/index.d.ts +1 -1
  60. package/dist/data-display/index.js +5 -4
  61. package/dist/data-display/styles.css +2 -4
  62. package/dist/feedback/index.cjs.map +1 -1
  63. package/dist/feedback/index.js +2 -2
  64. package/dist/feedback/styles.css +2 -4
  65. package/dist/forms/index.cjs +1211 -459
  66. package/dist/forms/index.cjs.map +1 -1
  67. package/dist/forms/index.d.cts +1 -1
  68. package/dist/forms/index.d.ts +1 -1
  69. package/dist/forms/index.js +8 -8
  70. package/dist/forms/styles.css +2 -6
  71. package/dist/glass/index.cjs.map +1 -1
  72. package/dist/glass/index.js +2 -2
  73. package/dist/glass/styles.css +2 -81
  74. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  75. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  76. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  77. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  78. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  79. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  80. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  81. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  82. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  83. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  84. package/dist/index.cjs +2481 -917
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +5 -5
  87. package/dist/index.d.ts +5 -5
  88. package/dist/index.js +19 -19
  89. package/dist/layout/index.cjs.map +1 -1
  90. package/dist/layout/index.js +2 -2
  91. package/dist/layout/styles.css +2 -8
  92. package/dist/navigation/index.cjs +288 -48
  93. package/dist/navigation/index.cjs.map +1 -1
  94. package/dist/navigation/index.d.cts +1 -1
  95. package/dist/navigation/index.d.ts +1 -1
  96. package/dist/navigation/index.js +6 -6
  97. package/dist/navigation/styles.css +2 -4
  98. package/dist/overlays/index.cjs +566 -229
  99. package/dist/overlays/index.cjs.map +1 -1
  100. package/dist/overlays/index.d.cts +1 -1
  101. package/dist/overlays/index.d.ts +1 -1
  102. package/dist/overlays/index.js +7 -7
  103. package/dist/overlays/styles.css +2 -8
  104. package/dist/reset.css +45 -0
  105. package/dist/styles.css +1 -1
  106. package/dist/tokens.css +11 -0
  107. package/dist/typography/index.cjs.map +1 -1
  108. package/dist/typography/index.js +4 -4
  109. package/dist/typography/styles.css +2 -5
  110. package/llms.txt +211 -113
  111. package/package.json +167 -116
  112. package/scripts/postinstall.js +125 -0
  113. package/src/bento/BentoCard.tsx +140 -140
  114. package/src/bento/BentoGrid.tsx +50 -50
  115. package/src/bento/index.ts +2 -2
  116. package/src/bento/tokens.css +46 -46
  117. package/src/buttons/Button.tsx +104 -104
  118. package/src/buttons/ButtonGroup.tsx +43 -43
  119. package/src/buttons/IconButton.tsx +79 -79
  120. package/src/buttons/SplitButton.tsx +186 -86
  121. package/src/buttons/ToggleButton.tsx +157 -157
  122. package/src/buttons/index.ts +5 -5
  123. package/src/buttons/tokens.css +6 -6
  124. package/src/core/ThemeProvider.tsx +135 -123
  125. package/src/core/index.ts +2 -2
  126. package/src/core/tokens.css +134 -134
  127. package/src/data-display/Accordion.tsx +147 -147
  128. package/src/data-display/Avatar.tsx +134 -134
  129. package/src/data-display/Card.tsx +112 -112
  130. package/src/data-display/Chip.tsx +74 -74
  131. package/src/data-display/DataTablePro.tsx +299 -121
  132. package/src/data-display/StatCard.tsx +60 -60
  133. package/src/data-display/Table.tsx +74 -74
  134. package/src/data-display/Timeline.tsx +62 -62
  135. package/src/data-display/TreeView.tsx +243 -104
  136. package/src/data-display/tokens.css +4 -4
  137. package/src/feedback/Alert.tsx +80 -80
  138. package/src/feedback/EmptyState.tsx +45 -45
  139. package/src/feedback/Skeleton.tsx +66 -66
  140. package/src/feedback/Spinner.tsx +65 -65
  141. package/src/feedback/index.ts +5 -5
  142. package/src/feedback/tokens.css +4 -4
  143. package/src/forms/Checkbox.tsx +149 -69
  144. package/src/forms/CheckboxGroup.tsx +76 -29
  145. package/src/forms/Combobox.tsx +172 -28
  146. package/src/forms/DatePicker.tsx +385 -81
  147. package/src/forms/FileUpload.tsx +51 -7
  148. package/src/forms/FormField.tsx +115 -115
  149. package/src/forms/Input.tsx +151 -151
  150. package/src/forms/NumberInput.tsx +34 -20
  151. package/src/forms/PasswordInput.tsx +35 -35
  152. package/src/forms/PinInput.tsx +139 -125
  153. package/src/forms/Radio.tsx +142 -142
  154. package/src/forms/RangeSlider.tsx +237 -111
  155. package/src/forms/SearchInput.tsx +34 -34
  156. package/src/forms/Select.tsx +103 -103
  157. package/src/forms/Slider.tsx +85 -85
  158. package/src/forms/Textarea.tsx +114 -114
  159. package/src/forms/tokens.css +6 -6
  160. package/src/glass/GlassButton.tsx +59 -59
  161. package/src/glass/GlassCard.tsx +71 -71
  162. package/src/glass/index.ts +3 -3
  163. package/src/glass/tokens.css +81 -81
  164. package/src/index.ts +26 -26
  165. package/src/layout/AspectRatio.tsx +39 -39
  166. package/src/layout/Container.tsx +53 -53
  167. package/src/layout/Divider.tsx +61 -61
  168. package/src/layout/Flex.tsx +79 -79
  169. package/src/layout/Grid.tsx +124 -124
  170. package/src/layout/PageLayout.tsx +59 -59
  171. package/src/layout/Section.tsx +47 -47
  172. package/src/layout/Spacer.tsx +30 -30
  173. package/src/layout/Stack.tsx +88 -88
  174. package/src/layout/tokens.css +8 -8
  175. package/src/navigation/Breadcrumb.tsx +73 -73
  176. package/src/navigation/CommandPalette.tsx +142 -142
  177. package/src/navigation/DropdownMenu.tsx +244 -160
  178. package/src/navigation/Pagination.tsx +114 -114
  179. package/src/navigation/Stepper.tsx +93 -93
  180. package/src/navigation/Tabs.tsx +265 -133
  181. package/src/navigation/index.ts +6 -6
  182. package/src/navigation/tokens.css +4 -4
  183. package/src/overlays/AlertDialog.tsx +63 -54
  184. package/src/overlays/BottomSheet.tsx +30 -10
  185. package/src/overlays/ContextMenu.tsx +51 -9
  186. package/src/overlays/Drawer.tsx +160 -145
  187. package/src/overlays/FocusTrap.tsx +122 -49
  188. package/src/overlays/Modal.tsx +296 -219
  189. package/src/overlays/Popover.tsx +55 -25
  190. package/src/overlays/Portal.tsx +23 -23
  191. package/src/overlays/Toast.tsx +175 -137
  192. package/src/overlays/Tooltip.tsx +125 -100
  193. package/src/overlays/tokens.css +8 -8
  194. package/src/registry/components.ts +1368 -0
  195. package/src/styles/components.css +39 -0
  196. package/src/styles/index.css +2 -93
  197. package/src/styles/reset.css +45 -0
  198. package/src/styles/tokens.css +11 -2
  199. package/src/typography/Blockquote.tsx +32 -32
  200. package/src/typography/Code.tsx +89 -89
  201. package/src/typography/Heading.tsx +56 -56
  202. package/src/typography/Kbd.tsx +25 -25
  203. package/src/typography/Label.tsx +47 -47
  204. package/src/typography/Link.tsx +56 -56
  205. package/src/typography/Text.tsx +62 -62
  206. package/src/typography/index.ts +7 -7
  207. package/src/typography/tokens.css +5 -5
  208. package/src/utils/cn.ts +6 -6
  209. package/src/utils/scroll-lock.ts +19 -0
  210. package/src/utils/useControllableState.ts +54 -0
  211. package/ui-contract.json +192 -35
  212. package/dist/chunk-2OXZK6IP.js.map +0 -1
  213. package/dist/chunk-4S6L5V7X.js.map +0 -1
  214. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  215. package/dist/chunk-66VWUJEI.js.map +0 -1
  216. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  217. package/dist/chunk-ATYGDTMK.js.map +0 -1
  218. package/dist/chunk-C4SUXMI3.js.map +0 -1
  219. package/dist/chunk-EU2LPUYP.js.map +0 -1
  220. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  221. package/dist/chunk-GXI2H5XA.js.map +0 -1
  222. package/dist/chunk-H3LCNPMC.js +0 -101
  223. package/dist/chunk-H3LCNPMC.js.map +0 -1
  224. package/dist/chunk-IABPUCAQ.js.map +0 -1
  225. package/dist/chunk-KJRBRTQE.js.map +0 -1
  226. package/dist/chunk-LBUBXUYQ.js +0 -40
  227. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  228. package/dist/chunk-LWFRUJRU.js.map +0 -1
  229. package/dist/chunk-NFVONJUK.js.map +0 -1
  230. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  231. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  232. package/dist/chunk-X76KBH4Y.js +0 -71
  233. package/dist/chunk-X76KBH4Y.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import React, { forwardRef, useState, useEffect } from 'react';
2
2
  import { Plus, Minus } from 'lucide-react';
3
3
  import { cn } from '../utils/cn';
4
+ import { useControllableState } from '../utils/useControllableState';
4
5
 
5
6
  export interface NumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue' | 'size'> {
6
7
  value?: number;
@@ -32,8 +33,12 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
32
33
  },
33
34
  ref
34
35
  ) => {
35
- const [internalVal, setInternalVal] = useState<number>(defaultValue);
36
- const value = controlledVal !== undefined ? controlledVal : internalVal;
36
+ const [value, setValue] = useControllableState<number>({
37
+ value: controlledVal,
38
+ defaultValue,
39
+ onChange,
40
+ name: 'NumberInput',
41
+ });
37
42
 
38
43
  const clamp = (num: number) => {
39
44
  let clamped = Math.min(max, Math.max(min, num));
@@ -46,15 +51,13 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
46
51
  const handleIncrement = () => {
47
52
  if (disabled) return;
48
53
  const next = clamp(value + step);
49
- setInternalVal(next);
50
- onChange?.(next);
54
+ setValue(next);
51
55
  };
52
56
 
53
57
  const handleDecrement = () => {
54
58
  if (disabled) return;
55
59
  const next = clamp(value - step);
56
- setInternalVal(next);
57
- onChange?.(next);
60
+ setValue(next);
58
61
  };
59
62
 
60
63
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -65,8 +68,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
65
68
  const parsed = parseFloat(val);
66
69
  if (!isNaN(parsed)) {
67
70
  const next = clamp(parsed);
68
- setInternalVal(next);
69
- onChange?.(next);
71
+ setValue(next);
70
72
  }
71
73
  };
72
74
 
@@ -89,7 +91,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
89
91
  type="button"
90
92
  disabled={disabled || value <= min}
91
93
  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)]"
94
+ 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)] cursor-pointer"
93
95
  >
94
96
  <Minus className="w-3.5 h-3.5" />
95
97
  </button>
@@ -109,7 +111,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
109
111
  type="button"
110
112
  disabled={disabled || value >= max}
111
113
  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)]"
114
+ 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)] cursor-pointer"
113
115
  >
114
116
  <Plus className="w-3.5 h-3.5" />
115
117
  </button>
@@ -139,7 +141,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
139
141
  type="button"
140
142
  disabled={disabled || value >= max}
141
143
  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"
144
+ 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 cursor-pointer"
143
145
  >
144
146
  <Plus className="w-3 h-3" />
145
147
  </button>
@@ -147,7 +149,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
147
149
  type="button"
148
150
  disabled={disabled || value <= min}
149
151
  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"
152
+ 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 cursor-pointer"
151
153
  >
152
154
  <Minus className="w-3 h-3" />
153
155
  </button>
@@ -195,22 +197,34 @@ export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
195
197
  const symbol = currencySymbol || (currency === 'VND' ? '₫' : currency === 'EUR' ? '€' : '$');
196
198
  const isSuffix = position === 'suffix' || currency === 'VND';
197
199
 
198
- const [internalVal, setInternalVal] = useState<number>(defaultValue);
199
- const value = controlledVal !== undefined ? controlledVal : internalVal;
200
- const [displayVal, setDisplayVal] = useState<string>(formatCurrencyString(value));
200
+ const [value, setValue, isControlled] = useControllableState<number>({
201
+ value: controlledVal,
202
+ defaultValue,
203
+ onChange,
204
+ name: 'CurrencyInput',
205
+ });
206
+
207
+ const [internalInputString, setInternalInputString] = useState<string>(formatCurrencyString(value));
201
208
 
209
+ // When value changes, update internal display for uncontrolled mode
202
210
  useEffect(() => {
203
- setDisplayVal(formatCurrencyString(value));
204
- }, [value]);
211
+ if (!isControlled) {
212
+ setInternalInputString(formatCurrencyString(value));
213
+ }
214
+ }, [value, isControlled]);
215
+
216
+ // For controlled inputs, display value is ALWAYS pure derived from the source-of-truth prop
217
+ const displayVal = isControlled ? formatCurrencyString(value) : internalInputString;
205
218
 
206
219
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
207
220
  const raw = e.target.value.replace(/[^0-9.]/g, '');
208
221
  const parsed = parseFloat(raw);
209
222
  const num = isNaN(parsed) ? 0 : parsed;
210
223
 
211
- setDisplayVal(raw ? formatCurrencyString(num) : '');
212
- setInternalVal(num);
213
- onChange?.(num);
224
+ if (!isControlled) {
225
+ setInternalInputString(raw ? formatCurrencyString(num) : '');
226
+ }
227
+ setValue(num);
214
228
  };
215
229
 
216
230
  return (
@@ -1,35 +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';
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';
@@ -1,125 +1,139 @@
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';
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 isControlled = controlledValue !== undefined;
34
+ const [internalPins, setInternalPins] = useState<string[]>(() => {
35
+ const initial = (controlledValue !== undefined ? controlledValue : defaultValue).slice(0, length);
36
+ return Array.from({ length }, (_, i) => initial[i] || '');
37
+ });
38
+
39
+ const pins = isControlled
40
+ ? Array.from({ length }, (_, i) => controlledValue[i] || '')
41
+ : internalPins;
42
+
43
+ const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
44
+
45
+ useEffect(() => {
46
+ if (autoFocus && inputsRef.current[0]) {
47
+ inputsRef.current[0].focus();
48
+ }
49
+ }, [autoFocus]);
50
+
51
+ const handleDigitChange = (index: number, val: string) => {
52
+ const sanitized = val.replace(/[^0-9a-zA-Z]/g, '').slice(-1);
53
+ const newPins = [...pins];
54
+ newPins[index] = sanitized;
55
+ if (!isControlled) {
56
+ setInternalPins(newPins);
57
+ }
58
+
59
+ const combined = newPins.join('');
60
+ onChange?.(combined);
61
+
62
+ if (sanitized && index < length - 1) {
63
+ inputsRef.current[index + 1]?.focus();
64
+ }
65
+
66
+ if (combined.length === length && !newPins.includes('')) {
67
+ onComplete?.(combined);
68
+ }
69
+ };
70
+
71
+ const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
72
+ if (e.key === 'Backspace') {
73
+ if (!pins[index] && index > 0) {
74
+ const newPins = [...pins];
75
+ newPins[index - 1] = '';
76
+ if (!isControlled) setInternalPins(newPins);
77
+ onChange?.(newPins.join(''));
78
+ inputsRef.current[index - 1]?.focus();
79
+ }
80
+ } else if (e.key === 'ArrowLeft' && index > 0) {
81
+ e.preventDefault();
82
+ inputsRef.current[index - 1]?.focus();
83
+ } else if (e.key === 'ArrowRight' && index < length - 1) {
84
+ e.preventDefault();
85
+ inputsRef.current[index + 1]?.focus();
86
+ }
87
+ };
88
+
89
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
90
+ e.preventDefault();
91
+ const pasted = e.clipboardData.getData('text').replace(/[^0-9a-zA-Z]/g, '').slice(0, length);
92
+ if (!pasted) return;
93
+
94
+ const newPins = Array.from({ length }, (_, i) => pasted[i] || '');
95
+ if (!isControlled) setInternalPins(newPins);
96
+
97
+ const combined = newPins.join('');
98
+ onChange?.(combined);
99
+
100
+ const nextEmptyIndex = newPins.findIndex((p) => !p);
101
+ const focusIndex = nextEmptyIndex === -1 ? length - 1 : nextEmptyIndex;
102
+ inputsRef.current[focusIndex]?.focus();
103
+
104
+ if (combined.length === length && !newPins.includes('')) {
105
+ onComplete?.(combined);
106
+ }
107
+ };
108
+
109
+ return (
110
+ <div ref={ref} role="group" aria-label="PIN Code Input" className={cn('flex items-center gap-2', className)} {...props}>
111
+ {Array.from({ length }).map((_, index) => (
112
+ <input
113
+ key={index}
114
+ ref={(el) => {
115
+ inputsRef.current[index] = el;
116
+ }}
117
+ type={mask ? 'password' : 'text'}
118
+ inputMode="numeric"
119
+ maxLength={1}
120
+ disabled={disabled}
121
+ aria-label={`Digit ${index + 1} of ${length}`}
122
+ value={pins[index]}
123
+ onChange={(e) => handleDigitChange(index, e.target.value)}
124
+ onKeyDown={(e) => handleKeyDown(index, e)}
125
+ onPaste={handlePaste}
126
+ className={cn(
127
+ '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',
128
+ isInvalid
129
+ ? 'border-[var(--ui-error)] focus-visible:ring-[var(--ui-error)]'
130
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
131
+ )}
132
+ />
133
+ ))}
134
+ </div>
135
+ );
136
+ }
137
+ );
138
+
139
+ PinInput.displayName = 'PinInput';