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,82 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Input } from "./input";
3
+
4
+ /*
5
+ * ============================================================================
6
+ * Types & Interfaces
7
+ * ============================================================================
8
+ */
9
+
10
+ interface PhoneInputProps extends React.ComponentProps<typeof Input> {
11
+ /* Maximum characters allowed in phone number input string */
12
+ maxLength?: number;
13
+ }
14
+
15
+ /*
16
+ * ============================================================================
17
+ * PhoneInput Component
18
+ * ============================================================================
19
+ */
20
+
21
+ export const PhoneInput = ({
22
+ maxLength = 10,
23
+ onChange,
24
+ ...props
25
+ }: PhoneInputProps) => {
26
+ /* Base phone string value state */
27
+ const [value, setValue] = useState((props.value as string) || "");
28
+
29
+ /* Synchronize value shifts programmatically from parents */
30
+ useEffect(() => {
31
+ if (props.value !== undefined) {
32
+ setValue(String(props.value));
33
+ }
34
+ }, [props.value]);
35
+
36
+ /*
37
+ * ------------------------------------------------------------------------
38
+ * Helper Math & Formatting Logic
39
+ * ------------------------------------------------------------------------
40
+ */
41
+
42
+ /* Filters input to retain only raw numerical digits up to maxLength */
43
+ const formatPhoneNumber = (digits: string) => {
44
+ if (!digits) return "";
45
+ return digits.replace(/\D/g, "").slice(0, maxLength);
46
+ };
47
+
48
+ /*
49
+ * ------------------------------------------------------------------------
50
+ * Event Action Handlers
51
+ * ------------------------------------------------------------------------
52
+ */
53
+
54
+ /* Text input change handler: sanitizes and propagates to parents */
55
+ const handleInternalChange = (e: React.ChangeEvent<HTMLInputElement>) => {
56
+ const val = formatPhoneNumber(e.target.value);
57
+ setValue(val);
58
+ const event = {
59
+ ...e,
60
+ target: {
61
+ ...e.target,
62
+ value: val,
63
+ },
64
+ } as React.ChangeEvent<HTMLInputElement>;
65
+ onChange?.(event);
66
+ };
67
+
68
+ /*
69
+ * ------------------------------------------------------------------------
70
+ * Render Component Markup
71
+ * ------------------------------------------------------------------------
72
+ */
73
+
74
+ return (
75
+ <Input
76
+ {...props}
77
+ type="tel"
78
+ value={value}
79
+ onChange={handleInternalChange}
80
+ />
81
+ );
82
+ };
@@ -0,0 +1,191 @@
1
+ import React from "react";
2
+ import { cn } from "@/utils/cn";
3
+ import { Radio, type RadioProps } from "./radio";
4
+
5
+ /*
6
+ * ============================================================================
7
+ * Types & Interfaces
8
+ * ============================================================================
9
+ */
10
+
11
+ /* Represents an individual radio item configuration */
12
+ export interface RadioOption {
13
+ id?: string;
14
+ label: string;
15
+ value: string;
16
+ description?: string;
17
+ disabled?: boolean;
18
+ }
19
+
20
+ /* Prop configuration for the parent RadioGroup component */
21
+ interface RadioGroupProps extends Omit<
22
+ RadioProps,
23
+ | "label"
24
+ | "description"
25
+ | "value"
26
+ | "onChange"
27
+ | "defaultValue"
28
+ | "options"
29
+ | "error"
30
+ > {
31
+ /* Title label of the group */
32
+ label?: string;
33
+ /* Help or descriptive text for the entire group */
34
+ description?: string;
35
+ /* Array of radio option objects */
36
+ options: RadioOption[];
37
+ /* Controlled selection value */
38
+ value?: string;
39
+ /* Default initial selection value */
40
+ defaultValue?: string;
41
+ /* Callback triggered on selection change */
42
+ onChange?: (value: string) => void;
43
+ /* Visual width class for the label area */
44
+ labelWidth?: string;
45
+ /* Horizontal alignment of the group label */
46
+ labelAlign?: "left" | "center" | "right";
47
+ /* Group-level validation error message */
48
+ error?: string;
49
+ }
50
+
51
+ /*
52
+ * ============================================================================
53
+ * RadioGroup Component
54
+ * ============================================================================
55
+ */
56
+
57
+ export const RadioGroup = ({
58
+ name,
59
+ label,
60
+ description,
61
+ error,
62
+ value,
63
+ defaultValue,
64
+ onChange,
65
+ options = [],
66
+ className,
67
+ labelWidth = "w-full",
68
+ labelAlign = "left",
69
+ ...props
70
+ }: RadioGroupProps) => {
71
+ const uniqueName = React.useId();
72
+ const groupName = name || uniqueName;
73
+
74
+ /* Pure Controlled State: Source value directly from parent-provided props */
75
+ const activeValue = value !== undefined ? value : (defaultValue || "");
76
+
77
+ /* Updates selected option and propagates changes back up */
78
+ const handleRadioChange = (optionValue: string) => {
79
+ onChange?.(optionValue);
80
+ };
81
+
82
+ return (
83
+ <div className={cn("flex w-full flex-col gap-3", className)}>
84
+ {/* Group Label Area (contains label and description) */}
85
+ {(label || description) && (
86
+ <div className={cn("flex flex-col shrink-0", labelWidth)}>
87
+ <div
88
+ className={cn(
89
+ "flex flex-col",
90
+ labelAlign === "left" && "items-start text-left",
91
+ labelAlign === "right" && "items-end text-right",
92
+ labelAlign === "center" && "items-center text-center",
93
+ )}
94
+ >
95
+ {label && (
96
+ <span className="tracking-tight uppercase text-sm font-bold text-black">
97
+ {label}
98
+ </span>
99
+ )}
100
+ {description && (
101
+ <span className="mt-0.5 leading-tight text-[11px] font-medium text-black">
102
+ {description}
103
+ </span>
104
+ )}
105
+ </div>
106
+ </div>
107
+ )}
108
+
109
+ {/* Radio Items Area (loops options and forwards rest parameters) */}
110
+ <div className="flex-1 flex flex-col gap-1.5">
111
+ <div className="flex gap-4 flex-col">
112
+ {options.map((option, index) => {
113
+ const isChecked = activeValue === option.value;
114
+
115
+ return (
116
+ <div
117
+ key={option.id || option.value || index}
118
+ className="flex items-center gap-3 w-full"
119
+ >
120
+ <Radio
121
+ name={groupName}
122
+ label={option.label}
123
+ description={option.description}
124
+ disabled={option.disabled}
125
+ checked={isChecked}
126
+ onChange={() => handleRadioChange(option.value)}
127
+ className="flex-1 min-w-0"
128
+ value={option.value}
129
+ {...props}
130
+ />
131
+ </div>
132
+ );
133
+ })}
134
+ </div>
135
+
136
+ {/* Group Validation Error Message (displayed only once) */}
137
+ {error && (
138
+ <span className="text-[10px] font-medium mt-1.5 ml-1 block animate-in fade-in slide-in-from-top-1 text-red-500">
139
+ {error}
140
+ </span>
141
+ )}
142
+ </div>
143
+ </div>
144
+ );
145
+ };
146
+
147
+ /*
148
+ * ============================================================================
149
+ * State Lifting Explanation & Usage Guide
150
+ * ============================================================================
151
+ *
152
+ * 1. What state was lifted up?
153
+ * The selected option state (`internalValue` string) has been lifted out of the
154
+ * local group scope. The local `useState` hook and lifecycle `useEffect`
155
+ * synchronization logic have been completely removed.
156
+ *
157
+ * 2. How to work with this pure controlled component (Standard React State):
158
+ * Define a string state and its setter in the parent component:
159
+ *
160
+ * const [selectedOpt, setSelectedOpt] = useState("email");
161
+ *
162
+ * Then render the component:
163
+ * <RadioGroup
164
+ * label="Notification Preference"
165
+ * value={selectedOpt}
166
+ * onChange={setSelectedOpt}
167
+ * options={[
168
+ * { label: "Email", value: "email" },
169
+ * { label: "SMS", value: "sms" }
170
+ * ]}
171
+ * />
172
+ *
173
+ * 3. React Hook Form Integration:
174
+ * When using with React Hook Form, wrap this component inside a <Controller>:
175
+ *
176
+ * <Controller
177
+ * name="notificationPref"
178
+ * control={control}
179
+ * render={({ field }) => (
180
+ * <RadioGroup
181
+ * label="Notification Preferences"
182
+ * value={field.value}
183
+ * onChange={field.onChange}
184
+ * options={[
185
+ * { label: "Email", value: "email" },
186
+ * { label: "SMS", value: "sms" }
187
+ * ]}
188
+ * />
189
+ * )}
190
+ * />
191
+ */
@@ -0,0 +1,157 @@
1
+ import React, { useState } from "react";
2
+ import { cn } from "@/utils/cn";
3
+
4
+ /*
5
+ * ============================================================================
6
+ * Types & Interfaces
7
+ * ============================================================================
8
+ */
9
+
10
+ /* Prop configuration for the Radio button component */
11
+ export interface RadioProps extends Omit<
12
+ React.InputHTMLAttributes<HTMLInputElement>,
13
+ "type"
14
+ > {
15
+ /* Title label of the radio option */
16
+ label?: string;
17
+ /* Help or descriptive text for the radio option */
18
+ description?: string;
19
+ /* Validation error message specific to this option */
20
+ error?: string;
21
+ /* Controls alignment of label relative to the radio dot */
22
+ labelPlacement?: "top" | "left" | "right";
23
+ /* Sizing parameter for the label container width */
24
+ labelWidth?: string;
25
+ /* Horizontal alignment of the text label */
26
+ "labelAlign-X"?: "left" | "center" | "right";
27
+ /* Vertical alignment of the text label */
28
+ "labelAlign-Y"?: "top" | "middle" | "bottom";
29
+ }
30
+
31
+ /*
32
+ * ============================================================================
33
+ * Radio Component
34
+ * ============================================================================
35
+ */
36
+
37
+ export const Radio = ({
38
+ label,
39
+ description,
40
+ error,
41
+ labelPlacement = "right",
42
+ labelWidth,
43
+ "labelAlign-X": labelAlignX,
44
+ "labelAlign-Y": labelAlignY = "middle",
45
+ className,
46
+ id,
47
+ ...props
48
+ }: RadioProps) => {
49
+ const radioId = id || React.useId();
50
+
51
+ /* Managed selection states supporting controlled and uncontrolled modes */
52
+ const [internalChecked, setInternalChecked] = useState(
53
+ props.defaultChecked || false,
54
+ );
55
+ const isControlled = props.checked !== undefined;
56
+ const checked = isControlled ? props.checked : internalChecked;
57
+
58
+ /* Form event delegation and local state synchrony on changes */
59
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
60
+ if (props.disabled) return;
61
+ const newChecked = e.target.checked;
62
+ if (!isControlled) setInternalChecked(newChecked);
63
+ props.onChange?.(e);
64
+ };
65
+
66
+ const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
67
+ const xAlignment =
68
+ labelAlignX || (labelPlacement === "left" ? "right" : "left");
69
+ const yAlignmentClass =
70
+ labelAlignY === "top"
71
+ ? "items-start"
72
+ : labelAlignY === "bottom"
73
+ ? "items-end"
74
+ : "items-center";
75
+
76
+ return (
77
+ <div className={cn("flex flex-col gap-1.5", className)}>
78
+ {/* Radio Input and Selection Wrapper Label */}
79
+ <label
80
+ htmlFor={radioId}
81
+ className={cn(
82
+ "group flex cursor-pointer select-none transition-all duration-200 gap-2",
83
+ labelPlacement === "top" && "flex-col",
84
+ /* labelPlacement 'left' aligns the radio button to the extreme right end */
85
+ labelPlacement === "left" &&
86
+ cn("flex-row-reverse justify-between w-full", yAlignmentClass),
87
+ labelPlacement === "right" &&
88
+ cn("flex-row justify-start", yAlignmentClass),
89
+ props.disabled && "opacity-50 cursor-not-allowed",
90
+ )}
91
+ >
92
+ <div className="relative flex items-center shrink-0">
93
+ <input
94
+ {...props}
95
+ type="radio"
96
+ id={radioId}
97
+ className="peer sr-only"
98
+ checked={checked}
99
+ onChange={handleChange}
100
+ />
101
+ {/* Outer circle indicator border */}
102
+ <div
103
+ className={cn(
104
+ "w-5 h-5 rounded-full border-[1.5px] border-black flex items-center justify-center bg-white",
105
+ checked && "scale-110",
106
+ "peer-focus-visible:ring-4 peer-focus-visible:ring-sky-500/10 peer-focus-visible:border-sky-500"
107
+ )}
108
+ >
109
+ {/* Inner selected dot */}
110
+ <div
111
+ className={cn(
112
+ "w-2.5 h-2.5 rounded-full bg-black",
113
+ checked ? "opacity-100 scale-100" : "opacity-0 scale-100",
114
+ )}
115
+ />
116
+ </div>
117
+ </div>
118
+
119
+ {/* Text Area (Option labels and descriptive help texts) */}
120
+ {(label || description) && (
121
+ <div
122
+ className={cn(
123
+ "flex flex-col gap-0.5 min-w-0",
124
+ isSideLabel ? labelWidth || "flex-1" : "w-full",
125
+ xAlignment === "left" && "items-start text-left",
126
+ xAlignment === "right" && "items-end text-right",
127
+ xAlignment === "center" && "items-center text-center",
128
+ )}
129
+ >
130
+ {label && (
131
+ <span className="whitespace-normal break-words w-full text-sm font-medium text-black">
132
+ {label}
133
+ {props.required && (
134
+ <span className="ml-1 font-black text-red-500">
135
+ *
136
+ </span>
137
+ )}
138
+ </span>
139
+ )}
140
+ {description && (
141
+ <span className="leading-tight whitespace-normal break-words w-full text-xs text-black">
142
+ {description}
143
+ </span>
144
+ )}
145
+ </div>
146
+ )}
147
+ </label>
148
+
149
+ {/* Option Validation Error Message */}
150
+ {error && (
151
+ <span className="text-xs font-medium ml-1 italic tracking-tight animate-in fade-in slide-in-from-top-1 text-red-500">
152
+ {error}
153
+ </span>
154
+ )}
155
+ </div>
156
+ );
157
+ };
@@ -0,0 +1,210 @@
1
+ import React from "react";
2
+ import { cn } from "@/utils/cn";
3
+
4
+ interface RangeSliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
5
+ /* Title label of the range slider */
6
+ label?: string;
7
+ /* Help or descriptive text for the slider */
8
+ description?: string;
9
+ /* Validation error message specific to the slider value */
10
+ error?: string;
11
+ /* Layout alignment position of the label relative to the slider bar */
12
+ labelDirection?:
13
+ | "label-left" | "label-left-top" | "label-left-bottom"
14
+ | "label-right" | "label-right-top" | "label-right-bottom"
15
+ | "label-top" | "label-top-right" | "label-top-center";
16
+ /* Custom visual width string for the label container */
17
+ labelWidth?: string;
18
+ /* Horizontal text alignment of the label */
19
+ labelAlign?: "left" | "center" | "right";
20
+ /* Visual flex-gap spacing class between the label and slider */
21
+ labelGap?: string;
22
+ /* Controls whether the current selected value is rendered numerically */
23
+ showValue?: boolean;
24
+ /* Custom unit string to append after the rendered numerical value */
25
+ valueSuffix?: string;
26
+ /* Callback triggered immediately on slider drag changes */
27
+ onChange?: (value: number) => void;
28
+ }
29
+
30
+ export const RangeSlider = ({
31
+ label,
32
+ description,
33
+ error,
34
+ labelDirection = "label-top",
35
+ labelWidth = "w-32",
36
+ labelAlign,
37
+ labelGap = "gap-4",
38
+ showValue = true,
39
+ valueSuffix = "",
40
+ min = 0,
41
+ max = 100,
42
+ step = 1,
43
+ onChange,
44
+ className,
45
+ id,
46
+ ...props
47
+ }: RangeSliderProps) => {
48
+ const sliderId = id || React.useId();
49
+
50
+ /* Pure Controlled State: Source value directly from parent-provided props */
51
+ const resolvedValue = props.value !== undefined ? Number(props.value) : Number(min);
52
+
53
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
54
+ const newValue = Number(e.target.value);
55
+ /* Notify parent immediately on slide changes */
56
+ onChange?.(newValue);
57
+ };
58
+
59
+ const percentage = ((resolvedValue - Number(min)) / (Number(max) - Number(min))) * 100;
60
+ const isSideLabel = labelDirection.startsWith("label-left") || labelDirection.startsWith("label-right");
61
+
62
+ const alignment = labelAlign || (
63
+ labelDirection.includes("-left") ? "left" :
64
+ labelDirection.includes("-right") ? "right" :
65
+ labelDirection.includes("-center") ? "center" : "left"
66
+ );
67
+
68
+ return (
69
+ <div
70
+ className={cn(
71
+ "flex w-full",
72
+ labelGap,
73
+ isSideLabel ? "flex-row" : "flex-col",
74
+ (labelDirection === "label-left" || labelDirection === "label-right") && "items-center",
75
+ labelDirection.endsWith("-top") && isSideLabel && "mt-1 items-start",
76
+ labelDirection.endsWith("-bottom") && isSideLabel && "items-end",
77
+ labelDirection.startsWith("label-right") && "flex-row-reverse",
78
+ className
79
+ )}
80
+ >
81
+ {label && (
82
+ <div
83
+ className={cn(
84
+ "flex flex-col",
85
+ isSideLabel ? "shrink-0" : "w-full",
86
+ labelDirection.endsWith("-top") && isSideLabel && "mt-1.5"
87
+ )}
88
+ >
89
+ <div className={cn(
90
+ isSideLabel ? labelWidth : "w-full",
91
+ "flex flex-col gap-1",
92
+ alignment === "left" && "items-start text-left",
93
+ alignment === "right" && "items-end text-right",
94
+ alignment === "center" && "items-center text-center"
95
+ )}>
96
+ {/* Row 1: Label & Number */}
97
+ <div className="flex justify-between items-center w-full gap-4">
98
+ <label
99
+ htmlFor={sliderId}
100
+ className="text-sm font-medium text-black cursor-pointer select-none whitespace-normal wrap-break-word"
101
+ >
102
+ {label}
103
+ </label>
104
+ {showValue && !isSideLabel && (
105
+ <span className="text-sm font-bold tabular-nums text-black shrink-0 leading-none">
106
+ {resolvedValue}{valueSuffix}
107
+ </span>
108
+ )}
109
+ </div>
110
+
111
+ {/* Row 2: Description */}
112
+ {description && (
113
+ <span className="text-xs text-black leading-tight whitespace-normal wrap-break-word w-full">
114
+ {description}
115
+ </span>
116
+ )}
117
+ </div>
118
+ </div>
119
+ )}
120
+
121
+ {/* Row 3: Slider */}
122
+ <div className="flex-1 flex items-center gap-4">
123
+ {showValue && isSideLabel && labelDirection.startsWith("label-left") && (
124
+ <span className="text-sm font-semibold tabular-nums text-black shrink-0 min-w-[3ch] text-right">
125
+ {resolvedValue}{valueSuffix}
126
+ </span>
127
+ )}
128
+
129
+ <div className="relative flex-1 flex items-center h-6 group">
130
+ <div className="absolute w-full h-[6px] bg-black/20 rounded-full overflow-hidden">
131
+ <div
132
+ className="h-full bg-black transition-all duration-100 ease-out"
133
+ style={{ width: `${percentage}%` }}
134
+ />
135
+ </div>
136
+
137
+ <input
138
+ {...props}
139
+ type="range"
140
+ id={sliderId}
141
+ min={min}
142
+ max={max}
143
+ step={step}
144
+ value={resolvedValue}
145
+ onChange={handleChange}
146
+ className={cn(
147
+ "absolute w-full h-6 appearance-none bg-transparent cursor-pointer z-10",
148
+ "focus:outline-none focus:ring-4 focus:ring-sky-500/10 rounded-lg",
149
+ "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-black/20 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:duration-150 [&::-webkit-slider-thumb]:active:scale-90",
150
+ "[&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border [&::-moz-range-thumb]:border-black/20 [&::-moz-range-thumb]:shadow-sm [&::-moz-range-thumb]:transition-transform [&::-moz-range-thumb]:duration-150 [&::-moz-range-thumb]:active:scale-90"
151
+ )}
152
+ />
153
+ </div>
154
+
155
+ {showValue && isSideLabel && labelDirection.startsWith("label-right") && (
156
+ <span className="text-sm font-semibold tabular-nums text-black shrink-0 min-w-[3ch] text-left">
157
+ {resolvedValue}{valueSuffix}
158
+ </span>
159
+ )}
160
+ </div>
161
+
162
+ {error && (
163
+ <span className="text-xs font-medium text-red-500 italic tracking-tight">
164
+ {error}
165
+ </span>
166
+ )}
167
+ </div>
168
+ );
169
+ };
170
+
171
+ /*
172
+ * ============================================================================
173
+ * State Lifting Explanation & Usage Guide
174
+ * ============================================================================
175
+ *
176
+ * 1. What state was lifted up?
177
+ * The slider value state (`internalValue` number) has been lifted out of the
178
+ * local component scope. The local `useState` hook and lifecycle `useEffect`
179
+ * synchronization logic have been completely removed.
180
+ *
181
+ * 2. How to work with this pure controlled component (Standard React State):
182
+ * Define a numeric state and its setter in the parent component:
183
+ *
184
+ * const [sliderVal, setSliderVal] = useState(50);
185
+ *
186
+ * Then render the component:
187
+ * <RangeSlider
188
+ * label="Expense Limit"
189
+ * min={0}
190
+ * max={100}
191
+ * value={sliderVal}
192
+ * onChange={setSliderVal}
193
+ * />
194
+ *
195
+ * 3. React Hook Form Integration:
196
+ * When using with React Hook Form, wrap this component inside a <Controller>:
197
+ *
198
+ * <Controller
199
+ * name="budgetLimit"
200
+ * control={control}
201
+ * render={({ field }) => (
202
+ * <RangeSlider
203
+ * label="Monthly Budget"
204
+ * value={field.value}
205
+ * onChange={field.onChange}
206
+ * />
207
+ * )}
208
+ * />
209
+ */
210
+