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.
@@ -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
+ };