pejay-ui 1.0.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.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/bin/cli.js +379 -0
- package/package.json +52 -0
- package/registry.json +350 -0
- package/templates/button/Button.tsx +156 -0
- package/templates/button/index.ts +2 -0
- package/templates/button/tooltip.tsx +124 -0
- package/templates/form/amount-input.tsx +252 -0
- package/templates/form/checkbox-group.tsx +235 -0
- package/templates/form/checkbox.tsx +148 -0
- package/templates/form/date-picker.tsx +647 -0
- package/templates/form/date-range-picker.tsx +1039 -0
- package/templates/form/email-input.tsx +55 -0
- package/templates/form/file-input.tsx +380 -0
- package/templates/form/index.ts +22 -0
- package/templates/form/input.tsx +255 -0
- package/templates/form/number-input.tsx +186 -0
- package/templates/form/password-input.tsx +233 -0
- package/templates/form/phone-input.tsx +82 -0
- package/templates/form/radio-group.tsx +191 -0
- package/templates/form/radio.tsx +157 -0
- package/templates/form/range-slider.tsx +210 -0
- package/templates/form/switch.tsx +134 -0
- package/templates/form/textarea.tsx +253 -0
- package/templates/form/time-picker.tsx +435 -0
- package/templates/form/time-range-picker.tsx +526 -0
- package/templates/form/url-input.tsx +81 -0
- package/templates/select-dropdown/index.ts +4 -0
- package/templates/select-dropdown/multiselect-input.tsx +687 -0
- package/templates/select-dropdown/select-input.tsx +565 -0
- package/utils/cn.ts +6 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* ============================================================================
|
|
6
|
+
* Types & Interfaces
|
|
7
|
+
* ============================================================================
|
|
8
|
+
*/
|
|
9
|
+
export interface InputProps extends Omit<
|
|
10
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
11
|
+
"prefix"
|
|
12
|
+
> {
|
|
13
|
+
/** Optional main text label displayed above or next to the input */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Optional secondary text helper details shown underneath the label */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Validation error text which triggers error borders and shows below the input */
|
|
18
|
+
error?: string;
|
|
19
|
+
/** Explicit left-side icon Node */
|
|
20
|
+
leftIcon?: React.ReactNode;
|
|
21
|
+
/** Optional right-side clickable/decorative icon Node */
|
|
22
|
+
rightIcon?: React.ReactNode;
|
|
23
|
+
/** Static text content displayed inside the input box on the left */
|
|
24
|
+
prefix?: React.ReactNode;
|
|
25
|
+
/** Static text content displayed inside the input box on the right */
|
|
26
|
+
suffix?: React.ReactNode;
|
|
27
|
+
/** Click action handler triggered when clicking the rightIcon */
|
|
28
|
+
onRightIconClick?: (e: React.MouseEvent) => void;
|
|
29
|
+
/** Controls layout position of label relative to the input box */
|
|
30
|
+
labelPlacement?: "top" | "left" | "right";
|
|
31
|
+
/** Sets standard width constraints when using horizontal/side labels */
|
|
32
|
+
labelWidth?: string;
|
|
33
|
+
/** Custom horizontal text alignments for the label content */
|
|
34
|
+
"labelAlign-X"?: "left" | "center" | "right";
|
|
35
|
+
/** Custom vertical cross-axis alignments for side label layout blocks */
|
|
36
|
+
"labelAlign-Y"?: "top" | "middle" | "bottom";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* ============================================================================
|
|
41
|
+
* Main Input Component (ForwardRef Enabled)
|
|
42
|
+
* ============================================================================
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
46
|
+
(
|
|
47
|
+
{
|
|
48
|
+
label,
|
|
49
|
+
description,
|
|
50
|
+
error,
|
|
51
|
+
leftIcon,
|
|
52
|
+
rightIcon,
|
|
53
|
+
prefix,
|
|
54
|
+
suffix,
|
|
55
|
+
onRightIconClick,
|
|
56
|
+
labelPlacement = "top",
|
|
57
|
+
labelWidth = "w-32",
|
|
58
|
+
"labelAlign-X": labelAlignX,
|
|
59
|
+
"labelAlign-Y": labelAlignY = "middle",
|
|
60
|
+
...props
|
|
61
|
+
},
|
|
62
|
+
ref,
|
|
63
|
+
) => {
|
|
64
|
+
/* Stores previous input content to revert/roll back invalid characters */
|
|
65
|
+
const prevValueRef = useRef(
|
|
66
|
+
props.value?.toString() || props.defaultValue?.toString() || "",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* ------------------------------------------------------------------------
|
|
71
|
+
* Event Handlers & Sanitizers
|
|
72
|
+
* ------------------------------------------------------------------------
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
76
|
+
let val = e.target.value;
|
|
77
|
+
|
|
78
|
+
/* Sanitization rules for numbers (Forces float formats, locks max value limits) */
|
|
79
|
+
if (props.type === "number") {
|
|
80
|
+
const cleanVal = val.replace(/[^0-9.]/g, "");
|
|
81
|
+
const dots = cleanVal.split(".").length - 1;
|
|
82
|
+
if (dots > 1) {
|
|
83
|
+
e.target.value = prevValueRef.current;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
val = cleanVal;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Sanitization rules for tel (Allows numeric dialing codes and keys) */
|
|
90
|
+
if (props.type === "tel") {
|
|
91
|
+
val = val.replace(/[^0-9+\s()-]/g, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
prevValueRef.current = val;
|
|
95
|
+
e.target.value = val;
|
|
96
|
+
props.onChange?.(e);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* ------------------------------------------------------------------------
|
|
101
|
+
* Layout & Position Compilations
|
|
102
|
+
* ------------------------------------------------------------------------
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
|
|
106
|
+
const xAlignment =
|
|
107
|
+
labelAlignX || (labelPlacement === "left" ? "right" : "left");
|
|
108
|
+
const yAlignmentClass =
|
|
109
|
+
labelAlignY === "top"
|
|
110
|
+
? "items-start"
|
|
111
|
+
: labelAlignY === "bottom"
|
|
112
|
+
? "items-end"
|
|
113
|
+
: "items-center";
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* ------------------------------------------------------------------------
|
|
117
|
+
* Renders Component Layout Tree
|
|
118
|
+
* ------------------------------------------------------------------------
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className={cn(
|
|
124
|
+
"flex w-full",
|
|
125
|
+
labelPlacement === "top" && "flex-col gap-1.5",
|
|
126
|
+
labelPlacement === "left" && cn("flex-row gap-4", yAlignmentClass),
|
|
127
|
+
labelPlacement === "right" &&
|
|
128
|
+
cn("flex-row-reverse gap-4", yAlignmentClass),
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{/* Standard Label & Optional Helper Text Block */}
|
|
132
|
+
{label && (
|
|
133
|
+
<div
|
|
134
|
+
className={cn(
|
|
135
|
+
"flex flex-col",
|
|
136
|
+
isSideLabel ? "shrink-0" : "w-full",
|
|
137
|
+
labelAlignY === "top" && isSideLabel && "mt-2.5",
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
isSideLabel ? labelWidth : "w-full",
|
|
143
|
+
"flex flex-col",
|
|
144
|
+
xAlignment === "left" && "items-start text-left",
|
|
145
|
+
xAlignment === "right" && "items-end text-right",
|
|
146
|
+
xAlignment === "center" && "items-center text-center",
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<span className="text-md font-medium text-black">{label}</span>
|
|
150
|
+
{description && (
|
|
151
|
+
<span className="text-xs text-black font-medium mt-0.5">
|
|
152
|
+
{description}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Input Wrapper Group */}
|
|
160
|
+
<div className="flex-1 min-w-0 flex flex-col relative group">
|
|
161
|
+
{/* Dynamic Flex Container representing the input borders and decorators */}
|
|
162
|
+
<div
|
|
163
|
+
className={cn(
|
|
164
|
+
"flex items-center transition-all duration-200 gap-0",
|
|
165
|
+
"rounded-lg w-full border-[1.5px] bg-white border-black h-9",
|
|
166
|
+
"focus-within:border-sky-500 focus-within:ring-4 focus-within:ring-sky-500/10",
|
|
167
|
+
error
|
|
168
|
+
? "border-red-600 focus-within:border-red-600 focus-within:ring-red-600/10"
|
|
169
|
+
: "",
|
|
170
|
+
props.readOnly && "cursor-pointer",
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
{/* Left Content Decorators (Icon / Prefix) */}
|
|
174
|
+
{(leftIcon || prefix) && (
|
|
175
|
+
<div
|
|
176
|
+
className={cn(
|
|
177
|
+
"flex items-center pl-2 pr-2 shrink-0 gap-1.5",
|
|
178
|
+
"text-black",
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
{leftIcon}
|
|
182
|
+
{prefix && <span className="font-medium">{prefix}</span>}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Native HTML Input Element */}
|
|
187
|
+
<input
|
|
188
|
+
ref={ref}
|
|
189
|
+
{...props}
|
|
190
|
+
type={
|
|
191
|
+
props.type === "number" || props.type === "tel"
|
|
192
|
+
? "text"
|
|
193
|
+
: props.type
|
|
194
|
+
}
|
|
195
|
+
inputMode={
|
|
196
|
+
props.type === "number"
|
|
197
|
+
? "decimal"
|
|
198
|
+
: props.type === "tel"
|
|
199
|
+
? "numeric"
|
|
200
|
+
: props.inputMode
|
|
201
|
+
}
|
|
202
|
+
onChange={handleChange}
|
|
203
|
+
placeholder={props.placeholder}
|
|
204
|
+
className={cn(
|
|
205
|
+
"flex-1 w-full min-w-0 bg-transparent border-none outline-none h-full py-1.5 truncate",
|
|
206
|
+
"text-md font-medium text-black",
|
|
207
|
+
"placeholder:text-black/40 placeholder:text-sm placeholder:font-medium placeholder:truncate",
|
|
208
|
+
!(leftIcon || prefix) && "pl-2",
|
|
209
|
+
!(rightIcon || suffix) && "pr-2",
|
|
210
|
+
props.readOnly && "cursor-pointer",
|
|
211
|
+
)}
|
|
212
|
+
/>
|
|
213
|
+
|
|
214
|
+
{/* Right Content Decorators (Suffix / Interactive Right Action Icon) */}
|
|
215
|
+
{(rightIcon || suffix) && (
|
|
216
|
+
<div
|
|
217
|
+
className={cn(
|
|
218
|
+
"flex items-center pr-2.25 pl-2 shrink-0",
|
|
219
|
+
"text-black",
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
{suffix && <span className="font-medium">{suffix}</span>}
|
|
223
|
+
{rightIcon && (
|
|
224
|
+
<div
|
|
225
|
+
onClick={onRightIconClick}
|
|
226
|
+
className={cn(
|
|
227
|
+
"transition-colors group-focus-within:text-white",
|
|
228
|
+
onRightIconClick && "cursor-pointer",
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
{rightIcon}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{/* Validation Error Message Display */}
|
|
239
|
+
{error && (
|
|
240
|
+
<span
|
|
241
|
+
className={cn(
|
|
242
|
+
"text-red-600 text-xs font-medium",
|
|
243
|
+
"mt-1.5 ml-1 block animate-in fade-in slide-in-from-top-1",
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
{error}
|
|
247
|
+
</span>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
Input.displayName = "Input";
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Input } from "./input";
|
|
3
|
+
import { Plus, Minus } from "lucide-react";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* ============================================================================
|
|
8
|
+
* Types & Interfaces
|
|
9
|
+
* ============================================================================
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface NumberInputProps extends React.ComponentProps<typeof Input> {
|
|
13
|
+
/* Controls visibility of right-side increment/decrement steppers */
|
|
14
|
+
showSteppers?: boolean;
|
|
15
|
+
/* Value delta to apply when stepping up or down */
|
|
16
|
+
step?: number;
|
|
17
|
+
/* Minimum value constraint */
|
|
18
|
+
min?: string | number;
|
|
19
|
+
/* Maximum value constraint */
|
|
20
|
+
max?: string | number;
|
|
21
|
+
/* Allows typing and submitting negative values */
|
|
22
|
+
allowNegative?: boolean;
|
|
23
|
+
/* Max number of decimal places allowed (undefined for unlimited) */
|
|
24
|
+
decimalScale?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* ============================================================================
|
|
29
|
+
* NumberInput Component
|
|
30
|
+
* ============================================================================
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const NumberInput = ({
|
|
34
|
+
showSteppers = true,
|
|
35
|
+
step = 1,
|
|
36
|
+
min,
|
|
37
|
+
max,
|
|
38
|
+
allowNegative = false,
|
|
39
|
+
decimalScale,
|
|
40
|
+
onChange,
|
|
41
|
+
className,
|
|
42
|
+
...props
|
|
43
|
+
}: NumberInputProps) => {
|
|
44
|
+
/* Base amount string value state */
|
|
45
|
+
const [value, setValue] = useState((props.value as string) || "");
|
|
46
|
+
|
|
47
|
+
/* Synchronize value shifts programmatically from parents */
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (props.value !== undefined) {
|
|
50
|
+
setValue(String(props.value));
|
|
51
|
+
}
|
|
52
|
+
}, [props.value]);
|
|
53
|
+
|
|
54
|
+
/* Default min to 0 unless allowNegative is enabled */
|
|
55
|
+
const resolvedMin = min !== undefined
|
|
56
|
+
? (typeof min === "number" ? min : parseFloat(String(min)))
|
|
57
|
+
: (allowNegative ? undefined : 0);
|
|
58
|
+
|
|
59
|
+
const resolvedMax = max !== undefined
|
|
60
|
+
? (typeof max === "number" ? max : parseFloat(String(max)))
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
/* Unified value updater: performs min/max boundaries and RHF event propagation */
|
|
64
|
+
const updateValue = (newVal: number | string) => {
|
|
65
|
+
let rawValue = String(newVal);
|
|
66
|
+
|
|
67
|
+
/* Sanitize non-numeric characters (allowing decimal point and negative sign) */
|
|
68
|
+
if (allowNegative) {
|
|
69
|
+
rawValue = rawValue.replace(/(?!^)-/g, "").replace(/[^\d.-]/g, "");
|
|
70
|
+
const dotParts = rawValue.split(".");
|
|
71
|
+
if (dotParts.length > 2) rawValue = `${dotParts[0]}.${dotParts[1]}`;
|
|
72
|
+
} else {
|
|
73
|
+
rawValue = rawValue.replace(/[^\d.]/g, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let numeric = parseFloat(rawValue);
|
|
77
|
+
|
|
78
|
+
/* Handle empty/NaN values, including intermediate minus sign */
|
|
79
|
+
if (isNaN(numeric)) {
|
|
80
|
+
const emptyVal = allowNegative && rawValue === "-" ? "-" : "";
|
|
81
|
+
setValue(emptyVal);
|
|
82
|
+
|
|
83
|
+
const event = {
|
|
84
|
+
target: { value: emptyVal },
|
|
85
|
+
} as React.ChangeEvent<HTMLInputElement>;
|
|
86
|
+
onChange?.(event);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Clamp numeric boundaries */
|
|
91
|
+
if (resolvedMin !== undefined && numeric < resolvedMin)
|
|
92
|
+
numeric = resolvedMin;
|
|
93
|
+
if (resolvedMax !== undefined && numeric > resolvedMax) numeric = resolvedMax;
|
|
94
|
+
|
|
95
|
+
/* Apply decimalScale truncation if provided */
|
|
96
|
+
let finalVal = String(numeric);
|
|
97
|
+
if (decimalScale !== undefined) {
|
|
98
|
+
const parts = String(numeric).split(".");
|
|
99
|
+
if (parts.length > 1) {
|
|
100
|
+
if (decimalScale === 0) {
|
|
101
|
+
finalVal = parts[0];
|
|
102
|
+
} else {
|
|
103
|
+
finalVal = `${parts[0]}.${parts[1].slice(0, decimalScale)}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Preserve the typed trailing dot (e.g. "2.") so they can continue typing decimals */
|
|
109
|
+
if (rawValue.endsWith(".") && !finalVal.includes(".")) {
|
|
110
|
+
finalVal += ".";
|
|
111
|
+
} else if (rawValue.includes(".") && decimalScale !== 0) {
|
|
112
|
+
const [_, fractional] = rawValue.split(".");
|
|
113
|
+
const [intPart] = finalVal.split(".");
|
|
114
|
+
const scale = decimalScale !== undefined ? decimalScale : fractional.length;
|
|
115
|
+
finalVal = `${intPart}.${fractional.slice(0, scale)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setValue(finalVal);
|
|
119
|
+
|
|
120
|
+
/* Propagate change up to React Hook Form listener */
|
|
121
|
+
const event = {
|
|
122
|
+
target: { value: finalVal },
|
|
123
|
+
} as React.ChangeEvent<HTMLInputElement>;
|
|
124
|
+
onChange?.(event);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/* Text input change handler: forwards to unified value updater */
|
|
128
|
+
const handleInternalChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
129
|
+
updateValue(e.target.value);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/* Stepper button increment click action */
|
|
133
|
+
const handleIncrement = () => {
|
|
134
|
+
const current = Number(value) || 0;
|
|
135
|
+
updateValue(current + step);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/* Stepper button decrement click action */
|
|
139
|
+
const handleDecrement = () => {
|
|
140
|
+
const current = Number(value) || 0;
|
|
141
|
+
updateValue(current - step);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const isNegativeValue = value.startsWith("-");
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Input
|
|
148
|
+
placeholder="0"
|
|
149
|
+
{...props}
|
|
150
|
+
type="text"
|
|
151
|
+
value={value}
|
|
152
|
+
onChange={handleInternalChange}
|
|
153
|
+
className={cn(isNegativeValue && "text-red-500 font-medium focus-within:border-red-500 focus-within:ring-red-500/20", className)}
|
|
154
|
+
rightIcon={
|
|
155
|
+
(showSteppers || props.rightIcon) && (
|
|
156
|
+
<div className="flex items-center gap-1.5 pl-2 ml-1">
|
|
157
|
+
{showSteppers && (
|
|
158
|
+
<div
|
|
159
|
+
className={cn(
|
|
160
|
+
"flex items-center gap-1",
|
|
161
|
+
props.rightIcon && "border-r border-gray-250 pr-2 mr-1",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={handleDecrement}
|
|
167
|
+
className="p-0.5 rounded transition-colors text-white bg-black hover:bg-gray-800"
|
|
168
|
+
>
|
|
169
|
+
<Minus size={14} />
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={handleIncrement}
|
|
174
|
+
className="p-0.5 rounded transition-colors text-white bg-black hover:bg-gray-800"
|
|
175
|
+
>
|
|
176
|
+
<Plus size={14} />
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
{props.rightIcon}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Input } from "./input";
|
|
3
|
+
import { Check, Eye, EyeOff, AlertTriangle } from "lucide-react";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* ============================================================================
|
|
8
|
+
* Types & Interfaces
|
|
9
|
+
* ============================================================================
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface PasswordInputProps extends React.ComponentProps<typeof Input> {
|
|
13
|
+
/* Controls visibility of the toggle password visibility button */
|
|
14
|
+
showToggle?: boolean;
|
|
15
|
+
/* Displays a warning banner when Caps Lock is toggled on */
|
|
16
|
+
showCapsLockWarning?: boolean;
|
|
17
|
+
/* Displays a password strength meter containing color-coded bars */
|
|
18
|
+
showStrengthMeter?: boolean;
|
|
19
|
+
/* Displays requirement checklists (length, number, uppercase, special) */
|
|
20
|
+
showRequirements?: boolean;
|
|
21
|
+
/* Displays a warning banner when spaces are entered in the input */
|
|
22
|
+
showWhitespaceWarning?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* ============================================================================
|
|
27
|
+
* PasswordInput Component
|
|
28
|
+
* ============================================================================
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const PasswordInput = ({
|
|
32
|
+
showToggle = true,
|
|
33
|
+
showCapsLockWarning,
|
|
34
|
+
showStrengthMeter,
|
|
35
|
+
showRequirements,
|
|
36
|
+
showWhitespaceWarning,
|
|
37
|
+
...props
|
|
38
|
+
}: PasswordInputProps) => {
|
|
39
|
+
/* Visibility and CapsLock detection states */
|
|
40
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
41
|
+
const [isCapsLockOn, setIsCapsLockOn] = useState(false);
|
|
42
|
+
|
|
43
|
+
/* Fallback internal value state for uncontrolled usage */
|
|
44
|
+
const [internalValue, setInternalValue] = useState(
|
|
45
|
+
props.defaultValue ? String(props.defaultValue) : "",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/* Prioritize parent value prop, fallback to internal state */
|
|
49
|
+
const value = props.value !== undefined ? String(props.value) : internalValue;
|
|
50
|
+
|
|
51
|
+
/* Toggle password plaintext visibility */
|
|
52
|
+
const toggleVisibility = () => setIsVisible(!isVisible);
|
|
53
|
+
|
|
54
|
+
/* Calculate password strength score based on length and patterns */
|
|
55
|
+
const calculateStrength = (val: string) => {
|
|
56
|
+
let score = 0;
|
|
57
|
+
if (!val) return 0;
|
|
58
|
+
if (val.length > 6) score++;
|
|
59
|
+
if (val.length > 10) score++;
|
|
60
|
+
if (/[A-Z]/.test(val)) score++;
|
|
61
|
+
if (/[0-9]/.test(val)) score++;
|
|
62
|
+
/* Exclude spaces from being counted as special characters */
|
|
63
|
+
if (/[^A-Za-z0-9\s]/.test(val)) score++;
|
|
64
|
+
return score;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const strength = calculateStrength(value);
|
|
68
|
+
|
|
69
|
+
/* Detect keyboard modifier state for Caps Lock */
|
|
70
|
+
const checkCapsLock = (
|
|
71
|
+
e: React.KeyboardEvent | React.MouseEvent | React.FocusEvent,
|
|
72
|
+
) => {
|
|
73
|
+
if (!showCapsLockWarning) return;
|
|
74
|
+
if ("getModifierState" in e) {
|
|
75
|
+
setIsCapsLockOn(e.getModifierState("CapsLock"));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/* Get strength color mapping based on calculated score */
|
|
80
|
+
const getStrengthColor = () => {
|
|
81
|
+
if (strength <= 2) return "bg-red-500";
|
|
82
|
+
if (strength <= 3) return "bg-amber-500";
|
|
83
|
+
if (strength <= 4) return "bg-yellow-500";
|
|
84
|
+
return "bg-green-500";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/* Get strength label text based on calculated score */
|
|
88
|
+
const getStrengthLabel = () => {
|
|
89
|
+
if (strength === 0) return "";
|
|
90
|
+
if (strength <= 2) return "Weak";
|
|
91
|
+
if (strength <= 3) return "Fair";
|
|
92
|
+
if (strength <= 4) return "Good";
|
|
93
|
+
return "Strong";
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/* Requirements tracking array containing checking logic */
|
|
97
|
+
const requirements = [
|
|
98
|
+
{ label: "At least 8 characters", met: value.length >= 8 },
|
|
99
|
+
{ label: "At least one number", met: /[0-9]/.test(value) },
|
|
100
|
+
{ label: "One uppercase letter", met: /[A-Z]/.test(value) },
|
|
101
|
+
/* Exclude spaces from being counted as special characters */
|
|
102
|
+
{ label: "One special character", met: /[^A-Za-z0-9\s]/.test(value) },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex flex-col w-full gap-1">
|
|
107
|
+
{/* Base Input element */}
|
|
108
|
+
<Input
|
|
109
|
+
autoComplete="current-password"
|
|
110
|
+
{...props}
|
|
111
|
+
type={isVisible ? "text" : "password"}
|
|
112
|
+
onChange={e => {
|
|
113
|
+
const val = e.target.value;
|
|
114
|
+
setInternalValue(val);
|
|
115
|
+
props.onChange?.(e);
|
|
116
|
+
}}
|
|
117
|
+
onKeyUp={e => {
|
|
118
|
+
checkCapsLock(e);
|
|
119
|
+
props.onKeyUp?.(e);
|
|
120
|
+
}}
|
|
121
|
+
onFocus={e => {
|
|
122
|
+
checkCapsLock(e);
|
|
123
|
+
props.onFocus?.(e);
|
|
124
|
+
}}
|
|
125
|
+
onClick={e => {
|
|
126
|
+
checkCapsLock(e);
|
|
127
|
+
props.onClick?.(e);
|
|
128
|
+
}}
|
|
129
|
+
rightIcon={
|
|
130
|
+
showToggle ? (
|
|
131
|
+
isVisible ? (
|
|
132
|
+
<EyeOff
|
|
133
|
+
size={18}
|
|
134
|
+
className="text-black"
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<Eye
|
|
138
|
+
size={18}
|
|
139
|
+
className="text-black"
|
|
140
|
+
/>
|
|
141
|
+
)
|
|
142
|
+
) : (
|
|
143
|
+
props.rightIcon
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
onRightIconClick={
|
|
147
|
+
showToggle ? toggleVisibility : props.onRightIconClick
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
{/* Strength indicator bars */}
|
|
152
|
+
{showStrengthMeter && value !== "" && (
|
|
153
|
+
<div className="flex flex-col gap-1 px-1">
|
|
154
|
+
<div className="flex justify-between items-center">
|
|
155
|
+
<div className="flex gap-1 flex-1 h-1 mt-1">
|
|
156
|
+
{[1, 2, 3, 4, 5].map(step => (
|
|
157
|
+
<div
|
|
158
|
+
key={step}
|
|
159
|
+
className={cn(
|
|
160
|
+
"h-full flex-1 rounded-full transition-all duration-500",
|
|
161
|
+
strength >= step
|
|
162
|
+
? getStrengthColor()
|
|
163
|
+
: "bg-black/10",
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
<span
|
|
169
|
+
className={cn(
|
|
170
|
+
"text-[10px] font-medium ml-2 uppercase",
|
|
171
|
+
strength > 0 ? "opacity-100" : "opacity-0",
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{getStrengthLabel()}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{/* Requirement checks list */}
|
|
181
|
+
{showRequirements && value !== "" && (
|
|
182
|
+
<div className="flex flex-col gap-1.5 mt-2 px-1">
|
|
183
|
+
{requirements.map((req, i) => (
|
|
184
|
+
<div key={i} className="flex items-center gap-2">
|
|
185
|
+
<div
|
|
186
|
+
className={cn(
|
|
187
|
+
"w-3.5 h-3.5 rounded-full flex items-center justify-center border transition-all duration-300",
|
|
188
|
+
req.met ? "bg-green-500 border-green-500" : "border-gray-800",
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
{req.met && <Check size={10} className="text-white" />}
|
|
192
|
+
</div>
|
|
193
|
+
<span
|
|
194
|
+
className={cn(
|
|
195
|
+
"text-[11px] transition-colors duration-300",
|
|
196
|
+
req.met ? "text-green-500 font-medium" : "text-gray-500",
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{req.label}
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* Caps Lock warning banner */}
|
|
207
|
+
{showCapsLockWarning && isCapsLockOn && (
|
|
208
|
+
<div className={"flex items-center gap-1 mt-1 px-1"}>
|
|
209
|
+
<AlertTriangle
|
|
210
|
+
size={12}
|
|
211
|
+
className="text-amber-500"
|
|
212
|
+
/>
|
|
213
|
+
<span className="text-[10px] font-medium text-amber-600 uppercase tracking-wider">
|
|
214
|
+
Caps Lock is ON
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Whitespace warning banner */}
|
|
220
|
+
{showWhitespaceWarning && /\s/.test(value) && (
|
|
221
|
+
<div className={"flex items-center gap-1 mt-1 px-1"}>
|
|
222
|
+
<AlertTriangle
|
|
223
|
+
size={12}
|
|
224
|
+
className="text-red-500"
|
|
225
|
+
/>
|
|
226
|
+
<span className="text-[10px] font-medium text-red-500 uppercase tracking-wider">
|
|
227
|
+
Password contains spaces
|
|
228
|
+
</span>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
};
|