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,134 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
|
|
4
|
+
interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> {
|
|
5
|
+
/* Title label of the switch toggle */
|
|
6
|
+
label?: string;
|
|
7
|
+
/* Help or descriptive text for the switch */
|
|
8
|
+
description?: string;
|
|
9
|
+
/* Validation error message specific to this switch */
|
|
10
|
+
error?: string;
|
|
11
|
+
/* Controls placement of label relative to the switch toggle */
|
|
12
|
+
labelPlacement?: "top" | "left" | "right";
|
|
13
|
+
/* Sizing parameter for the label container width */
|
|
14
|
+
labelWidth?: string;
|
|
15
|
+
/* Horizontal alignment of the text label */
|
|
16
|
+
"labelAlign-X"?: "left" | "center" | "right";
|
|
17
|
+
/* Vertical alignment of the text label */
|
|
18
|
+
"labelAlign-Y"?: "top" | "middle" | "bottom";
|
|
19
|
+
/* Callback triggered on switch selection change */
|
|
20
|
+
onChange?: (checked: boolean) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Switch = ({
|
|
24
|
+
label,
|
|
25
|
+
description,
|
|
26
|
+
error,
|
|
27
|
+
labelPlacement = "right",
|
|
28
|
+
labelWidth,
|
|
29
|
+
"labelAlign-X": labelAlignX,
|
|
30
|
+
"labelAlign-Y": labelAlignY = "middle",
|
|
31
|
+
onChange,
|
|
32
|
+
className,
|
|
33
|
+
id,
|
|
34
|
+
...props
|
|
35
|
+
}: SwitchProps) => {
|
|
36
|
+
const switchId = id || React.useId();
|
|
37
|
+
|
|
38
|
+
/* Pure Controlled State: Read checked directly from parent-provided props */
|
|
39
|
+
const checked = props.checked || false;
|
|
40
|
+
|
|
41
|
+
const handleToggle = (e: React.MouseEvent | React.KeyboardEvent) => {
|
|
42
|
+
if (props.disabled) return;
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
/* Immediately notify parent of the state transition */
|
|
45
|
+
onChange?.(!checked);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
|
|
49
|
+
const xAlignment = labelAlignX || (labelPlacement === "left" ? "left" : labelPlacement === "right" ? "right" : "left");
|
|
50
|
+
const yAlignmentClass = labelAlignY === "top" ? "items-start" : labelAlignY === "bottom" ? "items-end" : "items-center";
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
54
|
+
<label
|
|
55
|
+
htmlFor={switchId}
|
|
56
|
+
onClick={handleToggle}
|
|
57
|
+
className={cn(
|
|
58
|
+
"group flex cursor-pointer select-none transition-all duration-200 gap-4",
|
|
59
|
+
labelPlacement === "top" && "flex-col",
|
|
60
|
+
labelPlacement === "left" && cn("flex-row-reverse justify-between w-full", yAlignmentClass),
|
|
61
|
+
labelPlacement === "right" && cn("flex-row justify-start", yAlignmentClass),
|
|
62
|
+
props.disabled && "opacity-50 cursor-not-allowed"
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="relative flex items-center shrink-0">
|
|
66
|
+
<input
|
|
67
|
+
{...props}
|
|
68
|
+
type="checkbox"
|
|
69
|
+
id={switchId}
|
|
70
|
+
className="peer sr-only"
|
|
71
|
+
checked={checked}
|
|
72
|
+
readOnly
|
|
73
|
+
/>
|
|
74
|
+
<div className={cn(
|
|
75
|
+
"w-[44px] h-[24px] rounded-full transition-all duration-200 ease-in-out border-[1.5px] border-black relative",
|
|
76
|
+
checked ? "bg-black" : "bg-black/10",
|
|
77
|
+
"peer-focus-visible:ring-4 peer-focus-visible:ring-sky-500/10 peer-focus-visible:border-sky-500"
|
|
78
|
+
)}>
|
|
79
|
+
<div className={cn(
|
|
80
|
+
"w-4 h-4 rounded-full transition-all duration-200 ease-in-out absolute top-[2.5px] left-[3.5px]",
|
|
81
|
+
checked ? "bg-white translate-x-[18px]" : "bg-black translate-x-0"
|
|
82
|
+
)} />
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{(label || description) && (
|
|
87
|
+
<div className={cn("flex flex-col gap-0.5 min-w-0", isSideLabel ? (labelWidth || "flex-1") : "w-full", xAlignment === "left" && "items-start text-left", xAlignment === "right" && "items-end text-right", xAlignment === "center" && "items-center text-center")}>
|
|
88
|
+
{label && <span className="text-sm font-medium text-black whitespace-normal break-words w-full">{label}{props.required && <span className="text-red-500 ml-1 font-black">*</span>}</span>}
|
|
89
|
+
{description && <span className="text-xs text-black leading-tight whitespace-normal break-words w-full">{description}</span>}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</label>
|
|
93
|
+
{error && <span className="text-xs font-medium text-red-500 ml-1 italic tracking-tight animate-in fade-in slide-in-from-top-1">{error}</span>}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* ============================================================================
|
|
100
|
+
* State Lifting Explanation & Usage Guide
|
|
101
|
+
* ============================================================================
|
|
102
|
+
*
|
|
103
|
+
* 1. What state was lifted up?
|
|
104
|
+
* The selection/toggle state (`checked` boolean) has been completely lifted
|
|
105
|
+
* out of the local component scope. The local `useState` hook and corresponding
|
|
106
|
+
* effects have been removed.
|
|
107
|
+
*
|
|
108
|
+
* 2. How to work with this pure controlled component (Standard React State):
|
|
109
|
+
* Define a boolean state and its setter in the parent component:
|
|
110
|
+
*
|
|
111
|
+
* const [isActive, setIsActive] = useState(false);
|
|
112
|
+
*
|
|
113
|
+
* Then render the component:
|
|
114
|
+
* <Switch
|
|
115
|
+
* label="Toggle Active State"
|
|
116
|
+
* checked={isActive}
|
|
117
|
+
* onChange={setIsActive}
|
|
118
|
+
* />
|
|
119
|
+
*
|
|
120
|
+
* 3. React Hook Form Integration:
|
|
121
|
+
* When using with React Hook Form, wrap this component inside a <Controller>:
|
|
122
|
+
*
|
|
123
|
+
* <Controller
|
|
124
|
+
* name="subscribe"
|
|
125
|
+
* control={control}
|
|
126
|
+
* render={({ field }) => (
|
|
127
|
+
* <Switch
|
|
128
|
+
* label="Subscribe"
|
|
129
|
+
* checked={field.value}
|
|
130
|
+
* onChange={field.onChange}
|
|
131
|
+
* />
|
|
132
|
+
* )}
|
|
133
|
+
* />
|
|
134
|
+
*/
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import React, { useState, useRef, useLayoutEffect } from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* ============================================================================
|
|
6
|
+
* Types & Interfaces for TextArea Component
|
|
7
|
+
* ============================================================================
|
|
8
|
+
*/
|
|
9
|
+
interface TextAreaProps extends Omit<
|
|
10
|
+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
11
|
+
"prefix"
|
|
12
|
+
> {
|
|
13
|
+
/** Main label displayed above or next to the textarea */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Helper description text shown below the label */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Validation error message that triggers error styling and displays at the bottom */
|
|
18
|
+
error?: string;
|
|
19
|
+
/** Corner style variant for the input container */
|
|
20
|
+
variant?: "rounded" | "curved" | "square";
|
|
21
|
+
/** Position of the label relative to the textarea input box */
|
|
22
|
+
labelPlacement?: "top" | "left" | "right";
|
|
23
|
+
/** Explicit width constraints when using horizontal/side labels */
|
|
24
|
+
labelWidth?: string;
|
|
25
|
+
/** Horizontal alignment of the label text */
|
|
26
|
+
"labelAlign-X"?: "left" | "center" | "right";
|
|
27
|
+
/** Vertical alignment of side labels relative to the input container */
|
|
28
|
+
"labelAlign-Y"?: "top" | "middle" | "bottom";
|
|
29
|
+
/** Auto-resizes the height of the textarea based on content length */
|
|
30
|
+
autoResize?: boolean;
|
|
31
|
+
/** Maximum height constraint when autoResize is enabled */
|
|
32
|
+
maxHeight?: string;
|
|
33
|
+
/** Native resize behavior constraint */
|
|
34
|
+
allowResize?: "none" | "both" | "vertical" | "horizontal";
|
|
35
|
+
/** Controls displaying character, word, or both counters in the bottom right corner */
|
|
36
|
+
showCount?: "characters" | "words" | "both" | "none";
|
|
37
|
+
/** Enforces a strict word count limit on user inputs */
|
|
38
|
+
maxWordLimit?: number;
|
|
39
|
+
/** Additional tailwind custom classes for the outer wrapper */
|
|
40
|
+
wrapperClassName?: string;
|
|
41
|
+
/** Additional tailwind custom classes for the textarea element */
|
|
42
|
+
inputClassName?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* ============================================================================
|
|
47
|
+
* TextArea Component (ForwardRef Enabled)
|
|
48
|
+
* ============================================================================
|
|
49
|
+
*/
|
|
50
|
+
export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|
51
|
+
(
|
|
52
|
+
{
|
|
53
|
+
label,
|
|
54
|
+
description,
|
|
55
|
+
error,
|
|
56
|
+
variant = "curved",
|
|
57
|
+
labelPlacement = "top",
|
|
58
|
+
labelWidth = "w-32",
|
|
59
|
+
"labelAlign-X": labelAlignX,
|
|
60
|
+
"labelAlign-Y": labelAlignY = "middle",
|
|
61
|
+
autoResize = false,
|
|
62
|
+
maxHeight,
|
|
63
|
+
allowResize = "none",
|
|
64
|
+
showCount = "none",
|
|
65
|
+
maxWordLimit,
|
|
66
|
+
wrapperClassName,
|
|
67
|
+
inputClassName,
|
|
68
|
+
className,
|
|
69
|
+
onFocus,
|
|
70
|
+
onBlur,
|
|
71
|
+
onChange,
|
|
72
|
+
...props
|
|
73
|
+
},
|
|
74
|
+
ref,
|
|
75
|
+
) => {
|
|
76
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
77
|
+
const [internalHasContent, setInternalHasContent] = useState(
|
|
78
|
+
!!props.defaultValue || !!props.value,
|
|
79
|
+
);
|
|
80
|
+
const [wordCount, setWordCount] = useState(0);
|
|
81
|
+
const [charCount, setCharCount] = useState(0);
|
|
82
|
+
const internalRef = useRef<HTMLTextAreaElement>(null);
|
|
83
|
+
|
|
84
|
+
// Merge outer forwarded ref with our internal ref for autoResize calculations
|
|
85
|
+
const setRefs = (node: HTMLTextAreaElement) => {
|
|
86
|
+
if (typeof ref === "function") {
|
|
87
|
+
ref(node);
|
|
88
|
+
} else if (ref) {
|
|
89
|
+
(ref as any).current = node;
|
|
90
|
+
}
|
|
91
|
+
(internalRef as any).current = node;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Calculate height adjustment on text change if autoResize is enabled
|
|
95
|
+
useLayoutEffect(() => {
|
|
96
|
+
if (autoResize && internalRef.current) {
|
|
97
|
+
internalRef.current.style.height = "auto";
|
|
98
|
+
const newHeight = internalRef.current.scrollHeight;
|
|
99
|
+
internalRef.current.style.height = maxHeight
|
|
100
|
+
? `${Math.min(newHeight, parseInt(maxHeight))}px`
|
|
101
|
+
: `${newHeight}px`;
|
|
102
|
+
}
|
|
103
|
+
}, [props.value, internalHasContent, autoResize, maxHeight]);
|
|
104
|
+
|
|
105
|
+
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
|
106
|
+
setIsFocused(true);
|
|
107
|
+
onFocus?.(e);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
|
111
|
+
setIsFocused(false);
|
|
112
|
+
setInternalHasContent(e.target.value !== "");
|
|
113
|
+
onBlur?.(e);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
117
|
+
const val = e.target.value;
|
|
118
|
+
|
|
119
|
+
// Enforce word limits if configured
|
|
120
|
+
if (maxWordLimit) {
|
|
121
|
+
const words = val.trim().split(/\s+/).filter(Boolean);
|
|
122
|
+
if (words.length > maxWordLimit) return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setInternalHasContent(val !== "");
|
|
126
|
+
setCharCount(val.length);
|
|
127
|
+
setWordCount(val.trim().split(/\s+/).filter(Boolean).length);
|
|
128
|
+
|
|
129
|
+
onChange?.(e);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
|
|
133
|
+
|
|
134
|
+
const xAlignment =
|
|
135
|
+
labelAlignX || (labelPlacement === "left" ? "right" : "left");
|
|
136
|
+
|
|
137
|
+
const yAlignmentClass =
|
|
138
|
+
labelAlignY === "top"
|
|
139
|
+
? "items-start"
|
|
140
|
+
: labelAlignY === "bottom"
|
|
141
|
+
? "items-end"
|
|
142
|
+
: "items-center";
|
|
143
|
+
|
|
144
|
+
const radiusClass =
|
|
145
|
+
variant === "square"
|
|
146
|
+
? "rounded-none"
|
|
147
|
+
: variant === "curved"
|
|
148
|
+
? "rounded-lg"
|
|
149
|
+
: "rounded-full";
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
className={cn(
|
|
154
|
+
"flex w-full",
|
|
155
|
+
labelPlacement === "top" && "flex-col gap-1.5",
|
|
156
|
+
labelPlacement === "left" && cn("flex-row gap-4", yAlignmentClass),
|
|
157
|
+
labelPlacement === "right" &&
|
|
158
|
+
cn("flex-row-reverse gap-4", yAlignmentClass),
|
|
159
|
+
className,
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{/* Standard Label Element */}
|
|
163
|
+
{label && (
|
|
164
|
+
<div
|
|
165
|
+
className={cn(
|
|
166
|
+
"flex flex-col",
|
|
167
|
+
isSideLabel ? "shrink-0" : "w-full",
|
|
168
|
+
labelAlignY === "top" && isSideLabel && "mt-2.5",
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<div
|
|
172
|
+
className={cn(
|
|
173
|
+
isSideLabel ? labelWidth : "w-full",
|
|
174
|
+
"flex flex-col",
|
|
175
|
+
xAlignment === "left" && "items-start text-left",
|
|
176
|
+
xAlignment === "right" && "items-end text-right",
|
|
177
|
+
xAlignment === "center" && "items-center text-center",
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<span className="text-md font-medium text-black">
|
|
181
|
+
{label}
|
|
182
|
+
</span>
|
|
183
|
+
{description && (
|
|
184
|
+
<span className="text-xs text-black font-medium mt-0.5">
|
|
185
|
+
{description}
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* Input Wrapper Group */}
|
|
193
|
+
<div className="flex-1 flex flex-col relative group">
|
|
194
|
+
{/* Styled wrapper container for borders and states */}
|
|
195
|
+
<div
|
|
196
|
+
className={cn(
|
|
197
|
+
"relative w-full bg-white border-[1.5px] border-black transition-all duration-200 min-h-[80px]",
|
|
198
|
+
radiusClass,
|
|
199
|
+
isFocused
|
|
200
|
+
? "border-sky-500 ring-4 ring-sky-500/10 shadow-sm"
|
|
201
|
+
: "hover:border-gray-800",
|
|
202
|
+
error ? "border-red-600 ring-4 ring-red-600/10" : "",
|
|
203
|
+
wrapperClassName,
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<textarea
|
|
207
|
+
ref={setRefs}
|
|
208
|
+
{...props}
|
|
209
|
+
onFocus={handleFocus}
|
|
210
|
+
onBlur={handleBlur}
|
|
211
|
+
onChange={handleChange}
|
|
212
|
+
placeholder={props.placeholder}
|
|
213
|
+
style={{ resize: allowResize }}
|
|
214
|
+
className={cn(
|
|
215
|
+
"w-full bg-transparent border-none text-md text-black outline-none p-2.5 pb-1",
|
|
216
|
+
"placeholder:text-black/40 placeholder:text-sm placeholder:font-medium",
|
|
217
|
+
autoResize && "overflow-hidden",
|
|
218
|
+
inputClassName,
|
|
219
|
+
)}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
{/* Display character/word counters in a dedicated footer row to prevent overlap */}
|
|
223
|
+
{showCount !== "none" && (
|
|
224
|
+
<div className="flex justify-end gap-3 px-4 pb-2 select-none pointer-events-none">
|
|
225
|
+
{(showCount === "characters" || showCount === "both") && (
|
|
226
|
+
<span className="text-[10px] font-semibold text-black/40 uppercase">
|
|
227
|
+
{charCount}
|
|
228
|
+
{props.maxLength ? ` / ${props.maxLength}` : ""} CHR
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
{(showCount === "words" || showCount === "both") && (
|
|
232
|
+
<span className="text-[10px] font-semibold text-black/40 uppercase">
|
|
233
|
+
{wordCount}
|
|
234
|
+
{maxWordLimit ? ` / ${maxWordLimit}` : ""} WRD
|
|
235
|
+
</span>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{/* Validation Error Message Display */}
|
|
242
|
+
{error && (
|
|
243
|
+
<span className="text-xs font-medium text-red-600 mt-1.5 ml-1 block animate-in fade-in slide-in-from-top-1">
|
|
244
|
+
{error}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
TextArea.displayName = "TextArea";
|