@thangnqdev/ui 0.1.2 → 0.2.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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/dist/bento/index.cjs.map +1 -1
  4. package/dist/bento/index.js +2 -2
  5. package/dist/bento/styles.css +2 -46
  6. package/dist/buttons/index.cjs +111 -18
  7. package/dist/buttons/index.cjs.map +1 -1
  8. package/dist/buttons/index.d.cts +1 -1
  9. package/dist/buttons/index.d.ts +1 -1
  10. package/dist/buttons/index.js +3 -3
  11. package/dist/buttons/styles.css +2 -6
  12. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  13. package/dist/chunk-2YJBW4E2.js.map +1 -0
  14. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  15. package/dist/chunk-4CRFNALF.js.map +1 -0
  16. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  17. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  18. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  19. package/dist/chunk-77KTYOMQ.js.map +1 -0
  20. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  21. package/dist/chunk-ADO3SVUA.js.map +1 -0
  22. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  23. package/dist/chunk-D7FB3TCA.js.map +1 -0
  24. package/dist/chunk-DSEL7ELZ.js +314 -0
  25. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  26. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  27. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  28. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  29. package/dist/chunk-GXWB6BSY.js.map +1 -0
  30. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  31. package/dist/chunk-I2FQBMVW.js.map +1 -0
  32. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  33. package/dist/chunk-I5O2WCGF.js.map +1 -0
  34. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  35. package/dist/chunk-ME4XILG5.js.map +1 -0
  36. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  37. package/dist/chunk-PC5SZFZC.js.map +1 -0
  38. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  39. package/dist/chunk-S6XJMYME.js.map +1 -0
  40. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  41. package/dist/chunk-V52JOSDF.js.map +1 -0
  42. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  43. package/dist/chunk-W5WNWTQC.js.map +1 -0
  44. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  45. package/dist/chunk-YJ64RI7J.js.map +1 -0
  46. package/dist/chunk-YOCGBORV.js +155 -0
  47. package/dist/chunk-YOCGBORV.js.map +1 -0
  48. package/dist/chunk-ZEDXARXW.js +96 -0
  49. package/dist/chunk-ZEDXARXW.js.map +1 -0
  50. package/dist/components.css +2 -0
  51. package/dist/core/index.cjs +33 -24
  52. package/dist/core/index.cjs.map +1 -1
  53. package/dist/core/index.js +2 -2
  54. package/dist/core/styles.css +2 -134
  55. package/dist/data-display/index.cjs +770 -247
  56. package/dist/data-display/index.cjs.map +1 -1
  57. package/dist/data-display/index.d.cts +1 -1
  58. package/dist/data-display/index.d.ts +1 -1
  59. package/dist/data-display/index.js +5 -4
  60. package/dist/data-display/styles.css +2 -4
  61. package/dist/feedback/index.cjs.map +1 -1
  62. package/dist/feedback/index.js +2 -2
  63. package/dist/feedback/styles.css +2 -4
  64. package/dist/forms/index.cjs +1211 -459
  65. package/dist/forms/index.cjs.map +1 -1
  66. package/dist/forms/index.d.cts +1 -1
  67. package/dist/forms/index.d.ts +1 -1
  68. package/dist/forms/index.js +8 -8
  69. package/dist/forms/styles.css +2 -6
  70. package/dist/glass/index.cjs.map +1 -1
  71. package/dist/glass/index.js +2 -2
  72. package/dist/glass/styles.css +2 -81
  73. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  74. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  75. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  76. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  77. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  78. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  79. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  80. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  81. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  82. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  83. package/dist/index.cjs +2481 -917
  84. package/dist/index.cjs.map +1 -1
  85. package/dist/index.d.cts +5 -5
  86. package/dist/index.d.ts +5 -5
  87. package/dist/index.js +19 -19
  88. package/dist/layout/index.cjs.map +1 -1
  89. package/dist/layout/index.js +2 -2
  90. package/dist/layout/styles.css +2 -8
  91. package/dist/navigation/index.cjs +288 -48
  92. package/dist/navigation/index.cjs.map +1 -1
  93. package/dist/navigation/index.d.cts +1 -1
  94. package/dist/navigation/index.d.ts +1 -1
  95. package/dist/navigation/index.js +6 -6
  96. package/dist/navigation/styles.css +2 -4
  97. package/dist/overlays/index.cjs +566 -229
  98. package/dist/overlays/index.cjs.map +1 -1
  99. package/dist/overlays/index.d.cts +1 -1
  100. package/dist/overlays/index.d.ts +1 -1
  101. package/dist/overlays/index.js +7 -7
  102. package/dist/overlays/styles.css +2 -8
  103. package/dist/reset.css +45 -0
  104. package/dist/styles.css +1 -1
  105. package/dist/tokens.css +11 -0
  106. package/dist/typography/index.cjs.map +1 -1
  107. package/dist/typography/index.js +4 -4
  108. package/dist/typography/styles.css +2 -5
  109. package/llms.txt +211 -113
  110. package/package.json +160 -116
  111. package/src/bento/BentoCard.tsx +140 -140
  112. package/src/bento/BentoGrid.tsx +50 -50
  113. package/src/bento/index.ts +2 -2
  114. package/src/bento/tokens.css +46 -46
  115. package/src/buttons/Button.tsx +104 -104
  116. package/src/buttons/ButtonGroup.tsx +43 -43
  117. package/src/buttons/IconButton.tsx +79 -79
  118. package/src/buttons/SplitButton.tsx +186 -86
  119. package/src/buttons/ToggleButton.tsx +157 -157
  120. package/src/buttons/index.ts +5 -5
  121. package/src/buttons/tokens.css +6 -6
  122. package/src/core/ThemeProvider.tsx +135 -123
  123. package/src/core/index.ts +2 -2
  124. package/src/core/tokens.css +134 -134
  125. package/src/data-display/Accordion.tsx +147 -147
  126. package/src/data-display/Avatar.tsx +134 -134
  127. package/src/data-display/Card.tsx +112 -112
  128. package/src/data-display/Chip.tsx +74 -74
  129. package/src/data-display/DataTablePro.tsx +299 -121
  130. package/src/data-display/StatCard.tsx +60 -60
  131. package/src/data-display/Table.tsx +74 -74
  132. package/src/data-display/Timeline.tsx +62 -62
  133. package/src/data-display/TreeView.tsx +243 -104
  134. package/src/data-display/tokens.css +4 -4
  135. package/src/feedback/Alert.tsx +80 -80
  136. package/src/feedback/EmptyState.tsx +45 -45
  137. package/src/feedback/Skeleton.tsx +66 -66
  138. package/src/feedback/Spinner.tsx +65 -65
  139. package/src/feedback/index.ts +5 -5
  140. package/src/feedback/tokens.css +4 -4
  141. package/src/forms/Checkbox.tsx +149 -69
  142. package/src/forms/CheckboxGroup.tsx +76 -29
  143. package/src/forms/Combobox.tsx +172 -28
  144. package/src/forms/DatePicker.tsx +385 -81
  145. package/src/forms/FileUpload.tsx +51 -7
  146. package/src/forms/FormField.tsx +115 -115
  147. package/src/forms/Input.tsx +151 -151
  148. package/src/forms/NumberInput.tsx +34 -20
  149. package/src/forms/PasswordInput.tsx +35 -35
  150. package/src/forms/PinInput.tsx +139 -125
  151. package/src/forms/Radio.tsx +142 -142
  152. package/src/forms/RangeSlider.tsx +237 -111
  153. package/src/forms/SearchInput.tsx +34 -34
  154. package/src/forms/Select.tsx +103 -103
  155. package/src/forms/Slider.tsx +85 -85
  156. package/src/forms/Textarea.tsx +114 -114
  157. package/src/forms/tokens.css +6 -6
  158. package/src/glass/GlassButton.tsx +59 -59
  159. package/src/glass/GlassCard.tsx +71 -71
  160. package/src/glass/index.ts +3 -3
  161. package/src/glass/tokens.css +81 -81
  162. package/src/index.ts +26 -26
  163. package/src/layout/AspectRatio.tsx +39 -39
  164. package/src/layout/Container.tsx +53 -53
  165. package/src/layout/Divider.tsx +61 -61
  166. package/src/layout/Flex.tsx +79 -79
  167. package/src/layout/Grid.tsx +124 -124
  168. package/src/layout/PageLayout.tsx +59 -59
  169. package/src/layout/Section.tsx +47 -47
  170. package/src/layout/Spacer.tsx +30 -30
  171. package/src/layout/Stack.tsx +88 -88
  172. package/src/layout/tokens.css +8 -8
  173. package/src/navigation/Breadcrumb.tsx +73 -73
  174. package/src/navigation/CommandPalette.tsx +142 -142
  175. package/src/navigation/DropdownMenu.tsx +244 -160
  176. package/src/navigation/Pagination.tsx +114 -114
  177. package/src/navigation/Stepper.tsx +93 -93
  178. package/src/navigation/Tabs.tsx +265 -133
  179. package/src/navigation/index.ts +6 -6
  180. package/src/navigation/tokens.css +4 -4
  181. package/src/overlays/AlertDialog.tsx +63 -54
  182. package/src/overlays/BottomSheet.tsx +30 -10
  183. package/src/overlays/ContextMenu.tsx +51 -9
  184. package/src/overlays/Drawer.tsx +160 -145
  185. package/src/overlays/FocusTrap.tsx +122 -49
  186. package/src/overlays/Modal.tsx +296 -219
  187. package/src/overlays/Popover.tsx +55 -25
  188. package/src/overlays/Portal.tsx +23 -23
  189. package/src/overlays/Toast.tsx +175 -137
  190. package/src/overlays/Tooltip.tsx +125 -100
  191. package/src/overlays/tokens.css +8 -8
  192. package/src/registry/components.ts +1368 -0
  193. package/src/styles/components.css +39 -0
  194. package/src/styles/index.css +2 -93
  195. package/src/styles/reset.css +45 -0
  196. package/src/styles/tokens.css +11 -2
  197. package/src/typography/Blockquote.tsx +32 -32
  198. package/src/typography/Code.tsx +89 -89
  199. package/src/typography/Heading.tsx +56 -56
  200. package/src/typography/Kbd.tsx +25 -25
  201. package/src/typography/Label.tsx +47 -47
  202. package/src/typography/Link.tsx +56 -56
  203. package/src/typography/Text.tsx +62 -62
  204. package/src/typography/index.ts +7 -7
  205. package/src/typography/tokens.css +5 -5
  206. package/src/utils/cn.ts +6 -6
  207. package/src/utils/scroll-lock.ts +19 -0
  208. package/src/utils/useControllableState.ts +54 -0
  209. package/ui-contract.json +192 -35
  210. package/dist/chunk-2OXZK6IP.js.map +0 -1
  211. package/dist/chunk-4S6L5V7X.js.map +0 -1
  212. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  213. package/dist/chunk-66VWUJEI.js.map +0 -1
  214. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  215. package/dist/chunk-ATYGDTMK.js.map +0 -1
  216. package/dist/chunk-C4SUXMI3.js.map +0 -1
  217. package/dist/chunk-EU2LPUYP.js.map +0 -1
  218. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  219. package/dist/chunk-GXI2H5XA.js.map +0 -1
  220. package/dist/chunk-H3LCNPMC.js +0 -101
  221. package/dist/chunk-H3LCNPMC.js.map +0 -1
  222. package/dist/chunk-IABPUCAQ.js.map +0 -1
  223. package/dist/chunk-KJRBRTQE.js.map +0 -1
  224. package/dist/chunk-LBUBXUYQ.js +0 -40
  225. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  226. package/dist/chunk-LWFRUJRU.js.map +0 -1
  227. package/dist/chunk-NFVONJUK.js.map +0 -1
  228. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  229. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  230. package/dist/chunk-X76KBH4Y.js +0 -71
  231. 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';