@thangnqdev/ui 0.2.1 → 0.2.3
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.
- package/LICENSE +21 -21
- package/README.md +144 -142
- package/bin/cli.js +125 -125
- package/dist/bento/index.cjs.map +1 -1
- package/dist/bento/index.js +2 -2
- package/dist/buttons/index.cjs.map +1 -1
- package/dist/buttons/index.js +3 -3
- package/dist/{chunk-EXFR2BFJ.js → chunk-2ANJFOIU.js} +38 -36
- package/dist/chunk-2ANJFOIU.js.map +1 -0
- package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
- package/dist/chunk-2OXZK6IP.js.map +1 -0
- package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
- package/dist/chunk-4GNFT2ZJ.js.map +1 -0
- package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
- package/dist/chunk-4S6L5V7X.js.map +1 -0
- package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
- package/dist/chunk-66VWUJEI.js.map +1 -0
- package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
- package/dist/chunk-7NMB2Y64.js.map +1 -0
- package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
- package/dist/chunk-A7P3O5Z5.js.map +1 -0
- package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
- package/dist/chunk-ATYGDTMK.js.map +1 -0
- package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
- package/dist/chunk-C4SUXMI3.js.map +1 -0
- package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
- package/dist/chunk-EU2LPUYP.js.map +1 -0
- package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
- package/dist/chunk-GXI2H5XA.js.map +1 -0
- package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
- package/dist/chunk-IABPUCAQ.js.map +1 -0
- package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
- package/dist/chunk-JOKEKOP3.js.map +1 -0
- package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
- package/dist/chunk-KUQWUTZA.js.map +1 -0
- package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
- package/dist/chunk-U3J4UPV3.js.map +1 -0
- package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
- package/dist/chunk-VE2PNSDQ.js.map +1 -0
- package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
- package/dist/chunk-WAFHC4DQ.js.map +1 -0
- package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
- package/dist/chunk-YJ67WYVW.js.map +1 -0
- package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
- package/dist/chunk-Z427NPMU.js.map +1 -0
- package/dist/core/index.cjs +34 -32
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/data-display/index.cjs.map +1 -1
- package/dist/data-display/index.js +5 -5
- package/dist/feedback/index.cjs.map +1 -1
- package/dist/feedback/index.js +2 -2
- package/dist/forms/index.cjs.map +1 -1
- package/dist/forms/index.js +8 -8
- package/dist/glass/index.cjs.map +1 -1
- package/dist/glass/index.js +2 -2
- package/dist/index.cjs +34 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -19
- package/dist/layout/index.cjs.map +1 -1
- package/dist/layout/index.js +2 -2
- package/dist/navigation/index.cjs.map +1 -1
- package/dist/navigation/index.js +6 -6
- package/dist/overlays/index.cjs.map +1 -1
- package/dist/overlays/index.js +7 -7
- package/dist/reset.css +45 -45
- package/dist/tokens.css +11 -11
- package/dist/typography/index.cjs.map +1 -1
- package/dist/typography/index.js +4 -4
- package/llms.txt +3 -3
- package/package.json +165 -165
- package/scripts/postinstall.js +125 -125
- package/src/bento/BentoCard.tsx +140 -140
- package/src/bento/BentoGrid.tsx +50 -50
- package/src/bento/index.ts +2 -2
- package/src/bento/tokens.css +46 -46
- package/src/buttons/Button.tsx +104 -104
- package/src/buttons/ButtonGroup.tsx +43 -43
- package/src/buttons/IconButton.tsx +79 -79
- package/src/buttons/SplitButton.tsx +186 -186
- package/src/buttons/ToggleButton.tsx +157 -157
- package/src/buttons/index.ts +5 -5
- package/src/buttons/tokens.css +6 -6
- package/src/core/ThemeProvider.tsx +124 -106
- package/src/core/index.ts +2 -2
- package/src/core/tokens.css +134 -134
- package/src/data-display/Accordion.tsx +147 -147
- package/src/data-display/Avatar.tsx +134 -134
- package/src/data-display/Card.tsx +112 -112
- package/src/data-display/Chip.tsx +74 -74
- package/src/data-display/DataTablePro.tsx +534 -534
- package/src/data-display/StatCard.tsx +60 -60
- package/src/data-display/Table.tsx +74 -74
- package/src/data-display/Timeline.tsx +62 -62
- package/src/data-display/TreeView.tsx +279 -279
- package/src/data-display/tokens.css +4 -4
- package/src/feedback/Alert.tsx +80 -80
- package/src/feedback/EmptyState.tsx +45 -45
- package/src/feedback/Skeleton.tsx +66 -66
- package/src/feedback/Spinner.tsx +65 -65
- package/src/feedback/index.ts +5 -5
- package/src/feedback/tokens.css +4 -4
- package/src/forms/Checkbox.tsx +149 -149
- package/src/forms/CheckboxGroup.tsx +76 -76
- package/src/forms/Combobox.tsx +512 -512
- package/src/forms/DatePicker.tsx +784 -784
- package/src/forms/FileUpload.tsx +266 -266
- package/src/forms/FormField.tsx +115 -115
- package/src/forms/Input.tsx +151 -151
- package/src/forms/NumberInput.tsx +262 -262
- package/src/forms/PasswordInput.tsx +35 -35
- package/src/forms/PinInput.tsx +139 -139
- package/src/forms/Radio.tsx +142 -142
- package/src/forms/RangeSlider.tsx +237 -237
- package/src/forms/SearchInput.tsx +34 -34
- package/src/forms/Select.tsx +103 -103
- package/src/forms/Slider.tsx +85 -85
- package/src/forms/Textarea.tsx +114 -114
- package/src/forms/tokens.css +6 -6
- package/src/glass/GlassButton.tsx +59 -59
- package/src/glass/GlassCard.tsx +71 -71
- package/src/glass/index.ts +3 -3
- package/src/glass/tokens.css +81 -81
- package/src/index.ts +26 -26
- package/src/layout/AspectRatio.tsx +39 -39
- package/src/layout/Container.tsx +53 -53
- package/src/layout/Divider.tsx +61 -61
- package/src/layout/Flex.tsx +79 -79
- package/src/layout/Grid.tsx +124 -124
- package/src/layout/PageLayout.tsx +59 -59
- package/src/layout/Section.tsx +47 -47
- package/src/layout/Spacer.tsx +30 -30
- package/src/layout/Stack.tsx +88 -88
- package/src/layout/tokens.css +8 -8
- package/src/navigation/Breadcrumb.tsx +73 -73
- package/src/navigation/CommandPalette.tsx +142 -142
- package/src/navigation/DropdownMenu.tsx +244 -244
- package/src/navigation/Pagination.tsx +114 -114
- package/src/navigation/Stepper.tsx +93 -93
- package/src/navigation/Tabs.tsx +265 -265
- package/src/navigation/index.ts +6 -6
- package/src/navigation/tokens.css +4 -4
- package/src/overlays/AlertDialog.tsx +63 -63
- package/src/overlays/BottomSheet.tsx +143 -143
- package/src/overlays/ContextMenu.tsx +161 -161
- package/src/overlays/Drawer.tsx +160 -160
- package/src/overlays/FocusTrap.tsx +122 -122
- package/src/overlays/Modal.tsx +296 -296
- package/src/overlays/Popover.tsx +156 -156
- package/src/overlays/Portal.tsx +23 -23
- package/src/overlays/Toast.tsx +175 -175
- package/src/overlays/Tooltip.tsx +125 -125
- package/src/overlays/tokens.css +8 -8
- package/src/registry/components.ts +1368 -1368
- package/src/styles/components.css +39 -39
- package/src/styles/index.css +5 -5
- package/src/styles/reset.css +45 -45
- package/src/styles/tokens.css +11 -11
- package/src/typography/Blockquote.tsx +32 -32
- package/src/typography/Code.tsx +89 -89
- package/src/typography/Heading.tsx +56 -56
- package/src/typography/Kbd.tsx +25 -25
- package/src/typography/Label.tsx +47 -47
- package/src/typography/Link.tsx +56 -56
- package/src/typography/Text.tsx +62 -62
- package/src/typography/index.ts +7 -7
- package/src/typography/tokens.css +5 -5
- package/src/utils/cn.ts +6 -6
- package/src/utils/scroll-lock.ts +19 -19
- package/src/utils/useControllableState.ts +54 -54
- package/ui-contract.json +1 -1
- package/dist/chunk-2YJBW4E2.js.map +0 -1
- package/dist/chunk-4CRFNALF.js.map +0 -1
- package/dist/chunk-4MS6ZHFQ.js.map +0 -1
- package/dist/chunk-77KTYOMQ.js.map +0 -1
- package/dist/chunk-ADO3SVUA.js.map +0 -1
- package/dist/chunk-D7FB3TCA.js.map +0 -1
- package/dist/chunk-DSEL7ELZ.js.map +0 -1
- package/dist/chunk-EXFR2BFJ.js.map +0 -1
- package/dist/chunk-GXWB6BSY.js.map +0 -1
- package/dist/chunk-I2FQBMVW.js.map +0 -1
- package/dist/chunk-I5O2WCGF.js.map +0 -1
- package/dist/chunk-ME4XILG5.js.map +0 -1
- package/dist/chunk-PC5SZFZC.js.map +0 -1
- package/dist/chunk-S6XJMYME.js.map +0 -1
- package/dist/chunk-V52JOSDF.js.map +0 -1
- package/dist/chunk-W5WNWTQC.js.map +0 -1
- package/dist/chunk-YJ64RI7J.js.map +0 -1
- package/dist/chunk-YOCGBORV.js.map +0 -1
- package/dist/chunk-ZEDXARXW.js.map +0 -1
|
@@ -1,262 +1,262 @@
|
|
|
1
|
-
import React, { forwardRef, useState, useEffect } from 'react';
|
|
2
|
-
import { Plus, Minus } from 'lucide-react';
|
|
3
|
-
import { cn } from '../utils/cn';
|
|
4
|
-
import { useControllableState } from '../utils/useControllableState';
|
|
5
|
-
|
|
6
|
-
export interface NumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue' | 'size'> {
|
|
7
|
-
value?: number;
|
|
8
|
-
defaultValue?: number;
|
|
9
|
-
onChange?: (val: number) => void;
|
|
10
|
-
min?: number;
|
|
11
|
-
max?: number;
|
|
12
|
-
step?: number;
|
|
13
|
-
precision?: number;
|
|
14
|
-
size?: 'sm' | 'md' | 'lg';
|
|
15
|
-
stepperPosition?: 'right' | 'split';
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
|
19
|
-
(
|
|
20
|
-
{
|
|
21
|
-
value: controlledVal,
|
|
22
|
-
defaultValue = 0,
|
|
23
|
-
onChange,
|
|
24
|
-
min = -Infinity,
|
|
25
|
-
max = Infinity,
|
|
26
|
-
step = 1,
|
|
27
|
-
precision = 0,
|
|
28
|
-
size = 'md',
|
|
29
|
-
stepperPosition = 'right',
|
|
30
|
-
disabled = false,
|
|
31
|
-
className,
|
|
32
|
-
...props
|
|
33
|
-
},
|
|
34
|
-
ref
|
|
35
|
-
) => {
|
|
36
|
-
const [value, setValue] = useControllableState<number>({
|
|
37
|
-
value: controlledVal,
|
|
38
|
-
defaultValue,
|
|
39
|
-
onChange,
|
|
40
|
-
name: 'NumberInput',
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const clamp = (num: number) => {
|
|
44
|
-
let clamped = Math.min(max, Math.max(min, num));
|
|
45
|
-
if (precision > 0) {
|
|
46
|
-
clamped = parseFloat(clamped.toFixed(precision));
|
|
47
|
-
}
|
|
48
|
-
return clamped;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const handleIncrement = () => {
|
|
52
|
-
if (disabled) return;
|
|
53
|
-
const next = clamp(value + step);
|
|
54
|
-
setValue(next);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const handleDecrement = () => {
|
|
58
|
-
if (disabled) return;
|
|
59
|
-
const next = clamp(value - step);
|
|
60
|
-
setValue(next);
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
64
|
-
const val = e.target.value;
|
|
65
|
-
if (val === '' || val === '-') {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
const parsed = parseFloat(val);
|
|
69
|
-
if (!isNaN(parsed)) {
|
|
70
|
-
const next = clamp(parsed);
|
|
71
|
-
setValue(next);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
76
|
-
if (e.key === 'ArrowUp') {
|
|
77
|
-
e.preventDefault();
|
|
78
|
-
handleIncrement();
|
|
79
|
-
} else if (e.key === 'ArrowDown') {
|
|
80
|
-
e.preventDefault();
|
|
81
|
-
handleDecrement();
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const heightClass = size === 'sm' ? 'h-8 text-xs' : size === 'lg' ? 'h-12 text-base' : 'h-10 text-sm';
|
|
86
|
-
|
|
87
|
-
if (stepperPosition === 'split') {
|
|
88
|
-
return (
|
|
89
|
-
<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)}>
|
|
90
|
-
<button
|
|
91
|
-
type="button"
|
|
92
|
-
disabled={disabled || value <= min}
|
|
93
|
-
onClick={handleDecrement}
|
|
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"
|
|
95
|
-
>
|
|
96
|
-
<Minus className="w-3.5 h-3.5" />
|
|
97
|
-
</button>
|
|
98
|
-
|
|
99
|
-
<input
|
|
100
|
-
ref={ref}
|
|
101
|
-
type="number"
|
|
102
|
-
value={value}
|
|
103
|
-
disabled={disabled}
|
|
104
|
-
onChange={handleInputChange}
|
|
105
|
-
onKeyDown={handleKeyDown}
|
|
106
|
-
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)}
|
|
107
|
-
{...props}
|
|
108
|
-
/>
|
|
109
|
-
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
disabled={disabled || value >= max}
|
|
113
|
-
onClick={handleIncrement}
|
|
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"
|
|
115
|
-
>
|
|
116
|
-
<Plus className="w-3.5 h-3.5" />
|
|
117
|
-
</button>
|
|
118
|
-
</div>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<div className={cn('relative w-full inline-flex items-center', className)}>
|
|
124
|
-
<input
|
|
125
|
-
ref={ref}
|
|
126
|
-
type="number"
|
|
127
|
-
value={value}
|
|
128
|
-
disabled={disabled}
|
|
129
|
-
onChange={handleInputChange}
|
|
130
|
-
onKeyDown={handleKeyDown}
|
|
131
|
-
className={cn(
|
|
132
|
-
'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',
|
|
133
|
-
heightClass,
|
|
134
|
-
disabled && 'opacity-50 cursor-not-allowed'
|
|
135
|
-
)}
|
|
136
|
-
{...props}
|
|
137
|
-
/>
|
|
138
|
-
|
|
139
|
-
<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">
|
|
140
|
-
<button
|
|
141
|
-
type="button"
|
|
142
|
-
disabled={disabled || value >= max}
|
|
143
|
-
onClick={handleIncrement}
|
|
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"
|
|
145
|
-
>
|
|
146
|
-
<Plus className="w-3 h-3" />
|
|
147
|
-
</button>
|
|
148
|
-
<button
|
|
149
|
-
type="button"
|
|
150
|
-
disabled={disabled || value <= min}
|
|
151
|
-
onClick={handleDecrement}
|
|
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"
|
|
153
|
-
>
|
|
154
|
-
<Minus className="w-3 h-3" />
|
|
155
|
-
</button>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
);
|
|
161
|
-
NumberInput.displayName = 'NumberInput';
|
|
162
|
-
|
|
163
|
-
/* -------------------------------------------------------------------------------------------------
|
|
164
|
-
* CurrencyInput Component
|
|
165
|
-
* -----------------------------------------------------------------------------------------------*/
|
|
166
|
-
export interface CurrencyInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> {
|
|
167
|
-
value?: number;
|
|
168
|
-
defaultValue?: number;
|
|
169
|
-
onChange?: (val: number) => void;
|
|
170
|
-
currency?: 'USD' | 'VND' | 'EUR' | string;
|
|
171
|
-
currencySymbol?: string;
|
|
172
|
-
position?: 'prefix' | 'suffix';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function formatCurrencyString(val: number, separator = ','): string {
|
|
176
|
-
if (isNaN(val)) return '0';
|
|
177
|
-
const parts = val.toString().split('.');
|
|
178
|
-
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
|
179
|
-
return parts.join('.');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
|
|
183
|
-
(
|
|
184
|
-
{
|
|
185
|
-
value: controlledVal,
|
|
186
|
-
defaultValue = 0,
|
|
187
|
-
onChange,
|
|
188
|
-
currency = 'USD',
|
|
189
|
-
currencySymbol,
|
|
190
|
-
position = 'prefix',
|
|
191
|
-
disabled = false,
|
|
192
|
-
className,
|
|
193
|
-
...props
|
|
194
|
-
},
|
|
195
|
-
ref
|
|
196
|
-
) => {
|
|
197
|
-
const symbol = currencySymbol || (currency === 'VND' ? '₫' : currency === 'EUR' ? '€' : '$');
|
|
198
|
-
const isSuffix = position === 'suffix' || currency === 'VND';
|
|
199
|
-
|
|
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));
|
|
208
|
-
|
|
209
|
-
// When value changes, update internal display for uncontrolled mode
|
|
210
|
-
useEffect(() => {
|
|
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;
|
|
218
|
-
|
|
219
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
220
|
-
const raw = e.target.value.replace(/[^0-9.]/g, '');
|
|
221
|
-
const parsed = parseFloat(raw);
|
|
222
|
-
const num = isNaN(parsed) ? 0 : parsed;
|
|
223
|
-
|
|
224
|
-
if (!isControlled) {
|
|
225
|
-
setInternalInputString(raw ? formatCurrencyString(num) : '');
|
|
226
|
-
}
|
|
227
|
-
setValue(num);
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
return (
|
|
231
|
-
<div className={cn('relative w-full flex items-center', className)}>
|
|
232
|
-
{!isSuffix && (
|
|
233
|
-
<span className="absolute left-3.5 text-sm font-bold text-[var(--ui-text-muted)] pointer-events-none font-mono">
|
|
234
|
-
{symbol}
|
|
235
|
-
</span>
|
|
236
|
-
)}
|
|
237
|
-
|
|
238
|
-
<input
|
|
239
|
-
ref={ref}
|
|
240
|
-
type="text"
|
|
241
|
-
value={displayVal}
|
|
242
|
-
disabled={disabled}
|
|
243
|
-
onChange={handleInputChange}
|
|
244
|
-
className={cn(
|
|
245
|
-
'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',
|
|
246
|
-
!isSuffix && 'pl-8',
|
|
247
|
-
isSuffix && 'pr-12',
|
|
248
|
-
disabled && 'opacity-50 cursor-not-allowed'
|
|
249
|
-
)}
|
|
250
|
-
{...props}
|
|
251
|
-
/>
|
|
252
|
-
|
|
253
|
-
{isSuffix && (
|
|
254
|
-
<span className="absolute right-3.5 text-xs font-bold text-[var(--ui-text-muted)] pointer-events-none font-mono uppercase">
|
|
255
|
-
{currency}
|
|
256
|
-
</span>
|
|
257
|
-
)}
|
|
258
|
-
</div>
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
);
|
|
262
|
-
CurrencyInput.displayName = 'CurrencyInput';
|
|
1
|
+
import React, { forwardRef, useState, useEffect } from 'react';
|
|
2
|
+
import { Plus, Minus } from 'lucide-react';
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
import { useControllableState } from '../utils/useControllableState';
|
|
5
|
+
|
|
6
|
+
export interface NumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue' | 'size'> {
|
|
7
|
+
value?: number;
|
|
8
|
+
defaultValue?: number;
|
|
9
|
+
onChange?: (val: number) => void;
|
|
10
|
+
min?: number;
|
|
11
|
+
max?: number;
|
|
12
|
+
step?: number;
|
|
13
|
+
precision?: number;
|
|
14
|
+
size?: 'sm' | 'md' | 'lg';
|
|
15
|
+
stepperPosition?: 'right' | 'split';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
|
19
|
+
(
|
|
20
|
+
{
|
|
21
|
+
value: controlledVal,
|
|
22
|
+
defaultValue = 0,
|
|
23
|
+
onChange,
|
|
24
|
+
min = -Infinity,
|
|
25
|
+
max = Infinity,
|
|
26
|
+
step = 1,
|
|
27
|
+
precision = 0,
|
|
28
|
+
size = 'md',
|
|
29
|
+
stepperPosition = 'right',
|
|
30
|
+
disabled = false,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
},
|
|
34
|
+
ref
|
|
35
|
+
) => {
|
|
36
|
+
const [value, setValue] = useControllableState<number>({
|
|
37
|
+
value: controlledVal,
|
|
38
|
+
defaultValue,
|
|
39
|
+
onChange,
|
|
40
|
+
name: 'NumberInput',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const clamp = (num: number) => {
|
|
44
|
+
let clamped = Math.min(max, Math.max(min, num));
|
|
45
|
+
if (precision > 0) {
|
|
46
|
+
clamped = parseFloat(clamped.toFixed(precision));
|
|
47
|
+
}
|
|
48
|
+
return clamped;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleIncrement = () => {
|
|
52
|
+
if (disabled) return;
|
|
53
|
+
const next = clamp(value + step);
|
|
54
|
+
setValue(next);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleDecrement = () => {
|
|
58
|
+
if (disabled) return;
|
|
59
|
+
const next = clamp(value - step);
|
|
60
|
+
setValue(next);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
64
|
+
const val = e.target.value;
|
|
65
|
+
if (val === '' || val === '-') {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const parsed = parseFloat(val);
|
|
69
|
+
if (!isNaN(parsed)) {
|
|
70
|
+
const next = clamp(parsed);
|
|
71
|
+
setValue(next);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
76
|
+
if (e.key === 'ArrowUp') {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
handleIncrement();
|
|
79
|
+
} else if (e.key === 'ArrowDown') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
handleDecrement();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const heightClass = size === 'sm' ? 'h-8 text-xs' : size === 'lg' ? 'h-12 text-base' : 'h-10 text-sm';
|
|
86
|
+
|
|
87
|
+
if (stepperPosition === 'split') {
|
|
88
|
+
return (
|
|
89
|
+
<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)}>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
disabled={disabled || value <= min}
|
|
93
|
+
onClick={handleDecrement}
|
|
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"
|
|
95
|
+
>
|
|
96
|
+
<Minus className="w-3.5 h-3.5" />
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
<input
|
|
100
|
+
ref={ref}
|
|
101
|
+
type="number"
|
|
102
|
+
value={value}
|
|
103
|
+
disabled={disabled}
|
|
104
|
+
onChange={handleInputChange}
|
|
105
|
+
onKeyDown={handleKeyDown}
|
|
106
|
+
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)}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
disabled={disabled || value >= max}
|
|
113
|
+
onClick={handleIncrement}
|
|
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"
|
|
115
|
+
>
|
|
116
|
+
<Plus className="w-3.5 h-3.5" />
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={cn('relative w-full inline-flex items-center', className)}>
|
|
124
|
+
<input
|
|
125
|
+
ref={ref}
|
|
126
|
+
type="number"
|
|
127
|
+
value={value}
|
|
128
|
+
disabled={disabled}
|
|
129
|
+
onChange={handleInputChange}
|
|
130
|
+
onKeyDown={handleKeyDown}
|
|
131
|
+
className={cn(
|
|
132
|
+
'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',
|
|
133
|
+
heightClass,
|
|
134
|
+
disabled && 'opacity-50 cursor-not-allowed'
|
|
135
|
+
)}
|
|
136
|
+
{...props}
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
<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">
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
disabled={disabled || value >= max}
|
|
143
|
+
onClick={handleIncrement}
|
|
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"
|
|
145
|
+
>
|
|
146
|
+
<Plus className="w-3 h-3" />
|
|
147
|
+
</button>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
disabled={disabled || value <= min}
|
|
151
|
+
onClick={handleDecrement}
|
|
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"
|
|
153
|
+
>
|
|
154
|
+
<Minus className="w-3 h-3" />
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
NumberInput.displayName = 'NumberInput';
|
|
162
|
+
|
|
163
|
+
/* -------------------------------------------------------------------------------------------------
|
|
164
|
+
* CurrencyInput Component
|
|
165
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
166
|
+
export interface CurrencyInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> {
|
|
167
|
+
value?: number;
|
|
168
|
+
defaultValue?: number;
|
|
169
|
+
onChange?: (val: number) => void;
|
|
170
|
+
currency?: 'USD' | 'VND' | 'EUR' | string;
|
|
171
|
+
currencySymbol?: string;
|
|
172
|
+
position?: 'prefix' | 'suffix';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatCurrencyString(val: number, separator = ','): string {
|
|
176
|
+
if (isNaN(val)) return '0';
|
|
177
|
+
const parts = val.toString().split('.');
|
|
178
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
|
179
|
+
return parts.join('.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
|
|
183
|
+
(
|
|
184
|
+
{
|
|
185
|
+
value: controlledVal,
|
|
186
|
+
defaultValue = 0,
|
|
187
|
+
onChange,
|
|
188
|
+
currency = 'USD',
|
|
189
|
+
currencySymbol,
|
|
190
|
+
position = 'prefix',
|
|
191
|
+
disabled = false,
|
|
192
|
+
className,
|
|
193
|
+
...props
|
|
194
|
+
},
|
|
195
|
+
ref
|
|
196
|
+
) => {
|
|
197
|
+
const symbol = currencySymbol || (currency === 'VND' ? '₫' : currency === 'EUR' ? '€' : '$');
|
|
198
|
+
const isSuffix = position === 'suffix' || currency === 'VND';
|
|
199
|
+
|
|
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));
|
|
208
|
+
|
|
209
|
+
// When value changes, update internal display for uncontrolled mode
|
|
210
|
+
useEffect(() => {
|
|
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;
|
|
218
|
+
|
|
219
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
220
|
+
const raw = e.target.value.replace(/[^0-9.]/g, '');
|
|
221
|
+
const parsed = parseFloat(raw);
|
|
222
|
+
const num = isNaN(parsed) ? 0 : parsed;
|
|
223
|
+
|
|
224
|
+
if (!isControlled) {
|
|
225
|
+
setInternalInputString(raw ? formatCurrencyString(num) : '');
|
|
226
|
+
}
|
|
227
|
+
setValue(num);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div className={cn('relative w-full flex items-center', className)}>
|
|
232
|
+
{!isSuffix && (
|
|
233
|
+
<span className="absolute left-3.5 text-sm font-bold text-[var(--ui-text-muted)] pointer-events-none font-mono">
|
|
234
|
+
{symbol}
|
|
235
|
+
</span>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
<input
|
|
239
|
+
ref={ref}
|
|
240
|
+
type="text"
|
|
241
|
+
value={displayVal}
|
|
242
|
+
disabled={disabled}
|
|
243
|
+
onChange={handleInputChange}
|
|
244
|
+
className={cn(
|
|
245
|
+
'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',
|
|
246
|
+
!isSuffix && 'pl-8',
|
|
247
|
+
isSuffix && 'pr-12',
|
|
248
|
+
disabled && 'opacity-50 cursor-not-allowed'
|
|
249
|
+
)}
|
|
250
|
+
{...props}
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
{isSuffix && (
|
|
254
|
+
<span className="absolute right-3.5 text-xs font-bold text-[var(--ui-text-muted)] pointer-events-none font-mono uppercase">
|
|
255
|
+
{currency}
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
CurrencyInput.displayName = 'CurrencyInput';
|
|
@@ -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';
|