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,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";