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,252 @@
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 AmountInputProps extends React.ComponentProps<typeof Input> {
13
+ /* Minimum value constraint */
14
+ min?: string | number;
15
+ /* Maximum value constraint */
16
+ max?: string | number;
17
+ /* Allows typing and submitting negative values */
18
+ allowNegative?: boolean;
19
+ /* Forces trailing decimal precision on blur (e.g. 10 -> 10.00) */
20
+ fixedDecimalOnBlur?: boolean;
21
+ /* Controls visibility of right-side increment/decrement steppers */
22
+ showSteppers?: boolean;
23
+ /* Value delta to apply when stepping up or down */
24
+ step?: number;
25
+ }
26
+
27
+ /*
28
+ * ============================================================================
29
+ * AmountInput Component
30
+ * ============================================================================
31
+ */
32
+
33
+ export const AmountInput = ({
34
+ min,
35
+ max,
36
+ allowNegative = false,
37
+ fixedDecimalOnBlur = false,
38
+ showSteppers = false,
39
+ step = 1,
40
+ onChange,
41
+ onBlur,
42
+ className,
43
+ ...props
44
+ }: AmountInputProps) => {
45
+ /* Base amount string value state */
46
+ const [value, setValue] = useState((props.value as string) || "");
47
+
48
+ /* Synchronize value shifts programmatically from parents */
49
+ useEffect(() => {
50
+ if (props.value !== undefined) {
51
+ setValue(String(props.value));
52
+ }
53
+ }, [props.value]);
54
+
55
+ /*
56
+ * ------------------------------------------------------------------------
57
+ * Helper Math & Formatting Logic
58
+ * ------------------------------------------------------------------------
59
+ */
60
+
61
+ /* Resolve the decimal scale: use max/min precision if present, else default to 2 */
62
+ const getPrecision = (num?: string | number) => {
63
+ if (num === undefined) return null;
64
+ const parts = String(num).split(".");
65
+ return parts.length > 1 ? parts[1].length : 0;
66
+ };
67
+
68
+ const maxPrecision = getPrecision(max);
69
+ const minPrecision = getPrecision(min);
70
+ let resolvedScale = 2;
71
+ if (maxPrecision !== null || minPrecision !== null) {
72
+ const derivedPrecision = Math.max(maxPrecision || 0, minPrecision || 0);
73
+ resolvedScale = derivedPrecision > 0 ? derivedPrecision : 2;
74
+ }
75
+
76
+ /* Default min to 0 unless allowNegative is enabled */
77
+ const resolvedMin = min !== undefined
78
+ ? (typeof min === "number" ? min : parseFloat(String(min)))
79
+ : (allowNegative ? undefined : 0);
80
+
81
+ const resolvedMax = max !== undefined
82
+ ? (typeof max === "number" ? max : parseFloat(String(max)))
83
+ : undefined;
84
+
85
+ /* Formats raw strings with dynamic decimal constraints and thousands groupings */
86
+ const formatAmount = (val: string) => {
87
+ if (!val || val === "-") return val;
88
+ const isNegative = val.startsWith("-");
89
+ const clean = val.replace(/[^\d.]/g, "");
90
+ const parts = clean.split(".");
91
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
92
+
93
+ let result = parts[0];
94
+ if (parts.length >= 2) {
95
+ result += `.${parts[1].slice(0, resolvedScale)}`;
96
+ }
97
+
98
+ return isNegative ? `-${result}` : result;
99
+ };
100
+
101
+ /* Unified value updater: performs min/max boundaries and RHF event propagation */
102
+ const updateValue = (newVal: string | number, baseEvent?: React.ChangeEvent<HTMLInputElement>) => {
103
+ const stringVal = String(newVal);
104
+ let numeric = parseFloat(stringVal.replace(/,/g, ""));
105
+
106
+ /* Handle empty/NaN values, including intermediate minus sign */
107
+ if (isNaN(numeric)) {
108
+ const emptyVal = allowNegative && stringVal === "-" ? "-" : "";
109
+ setValue(emptyVal);
110
+
111
+ const event = baseEvent ? {
112
+ ...baseEvent,
113
+ target: { ...baseEvent.target, value: emptyVal }
114
+ } : {
115
+ target: { value: emptyVal }
116
+ } as React.ChangeEvent<HTMLInputElement>;
117
+ onChange?.(event);
118
+ return;
119
+ }
120
+
121
+ /* Clamp numeric boundaries */
122
+ if (resolvedMin !== undefined && numeric < resolvedMin) numeric = resolvedMin;
123
+ if (resolvedMax !== undefined && numeric > resolvedMax) numeric = resolvedMax;
124
+
125
+ /* Build clean string value while preserving trailing decimals and typing state */
126
+ let finalVal = formatAmount(String(numeric));
127
+ if (stringVal.endsWith(".") && !finalVal.includes(".")) {
128
+ finalVal += ".";
129
+ } else if (stringVal.includes(".")) {
130
+ const [_, fractional] = stringVal.split(".");
131
+ const [formattedInt] = finalVal.split(".");
132
+ finalVal = `${formattedInt}.${fractional.slice(0, resolvedScale)}`;
133
+ }
134
+
135
+ setValue(finalVal);
136
+
137
+ /* Propagate change up to React Hook Form listener */
138
+ const event = baseEvent ? {
139
+ ...baseEvent,
140
+ target: { ...baseEvent.target, value: finalVal }
141
+ } : {
142
+ target: { value: finalVal }
143
+ } as React.ChangeEvent<HTMLInputElement>;
144
+ onChange?.(event);
145
+ };
146
+
147
+ /*
148
+ * ------------------------------------------------------------------------
149
+ * Event Action Handlers
150
+ * ------------------------------------------------------------------------
151
+ */
152
+
153
+ /* Stepper button increment click action */
154
+ const handleIncrement = () => {
155
+ const current = parseFloat(value.replace(/,/g, "")) || 0;
156
+ updateValue(current + step);
157
+ };
158
+
159
+ /* Stepper button decrement click action */
160
+ const handleDecrement = () => {
161
+ const current = parseFloat(value.replace(/,/g, "")) || 0;
162
+ updateValue(current - step);
163
+ };
164
+
165
+ /* Input blur handler: enforces trailing precision logic */
166
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
167
+ if (fixedDecimalOnBlur && value && value !== "-") {
168
+ const numeric = parseFloat(value.replace(/,/g, ""));
169
+ if (!isNaN(numeric)) {
170
+ let fixedValue = value;
171
+
172
+ /* Force decimal formatting on blur if no decimals exist */
173
+ if (!value.includes(".")) {
174
+ fixedValue = formatAmount(numeric.toFixed(resolvedScale));
175
+ }
176
+
177
+ if (fixedValue !== value) {
178
+ setValue(fixedValue);
179
+ const event = {
180
+ ...e,
181
+ target: { ...e.target, value: fixedValue },
182
+ } as unknown as React.ChangeEvent<HTMLInputElement>;
183
+ onChange?.(event);
184
+ }
185
+ }
186
+ }
187
+ onBlur?.(e);
188
+ };
189
+
190
+ /* Text input change handler: sanitizes and forwards to unified value updater */
191
+ const handleInternalChange = (e: React.ChangeEvent<HTMLInputElement>) => {
192
+ let rawValue = e.target.value;
193
+
194
+ /* Lock raw text patterns based on negation rules */
195
+ if (allowNegative) {
196
+ rawValue = rawValue.replace(/(?!^)-/g, "").replace(/[^\d.-]/g, "");
197
+ const dotParts = rawValue.split(".");
198
+ if (dotParts.length > 2) rawValue = `${dotParts[0]}.${dotParts[1]}`;
199
+ } else {
200
+ rawValue = rawValue.replace(/[^\d.]/g, "");
201
+ }
202
+
203
+ updateValue(rawValue, e);
204
+ };
205
+
206
+ /*
207
+ * ------------------------------------------------------------------------
208
+ * Render Component Markup
209
+ * ------------------------------------------------------------------------
210
+ */
211
+
212
+ return (
213
+ <Input
214
+ {...props}
215
+ type="text"
216
+ value={value}
217
+ onChange={handleInternalChange}
218
+ onBlur={handleBlur}
219
+ className={className}
220
+ rightIcon={
221
+ (showSteppers || props.rightIcon) && (
222
+ <div className="flex items-center gap-1.5 pl-2 ml-1">
223
+ {showSteppers && (
224
+ <div
225
+ className={cn(
226
+ "flex items-center gap-1",
227
+ props.rightIcon && "border-r border-gray-250 pr-2 mr-1"
228
+ )}
229
+ >
230
+ <button
231
+ type="button"
232
+ onClick={handleDecrement}
233
+ className="p-0.5 rounded transition-colors bg-black text-white cursor-pointer hover:bg-gray-800"
234
+ >
235
+ <Minus size={14} />
236
+ </button>
237
+ <button
238
+ type="button"
239
+ onClick={handleIncrement}
240
+ className="p-0.5 rounded transition-colors bg-black text-white cursor-pointer hover:bg-gray-800"
241
+ >
242
+ <Plus size={14} />
243
+ </button>
244
+ </div>
245
+ )}
246
+ {props.rightIcon}
247
+ </div>
248
+ )
249
+ }
250
+ />
251
+ );
252
+ };
@@ -0,0 +1,235 @@
1
+ import React from "react";
2
+ import { cn } from "@/utils/cn";
3
+ import { Checkbox, type CheckboxProps } from "./checkbox";
4
+
5
+ /*
6
+ * ============================================================================
7
+ * Types & Interfaces
8
+ * ============================================================================
9
+ */
10
+
11
+ /* Represents an individual checkbox item configuration */
12
+ interface CheckboxOption {
13
+ id?: string;
14
+ label: string;
15
+ value: string;
16
+ description?: string;
17
+ disabled?: boolean;
18
+ indicator?: React.ReactNode | "bullet" | "number";
19
+ }
20
+
21
+ /* Prop configuration for the parent CheckboxGroup component extending CheckboxProps */
22
+ interface CheckboxGroupProps extends Omit<
23
+ CheckboxProps,
24
+ | "label"
25
+ | "description"
26
+ | "value"
27
+ | "onChange"
28
+ | "defaultValue"
29
+ | "options"
30
+ | "error"
31
+ > {
32
+ /* Title label of the group */
33
+ label?: string;
34
+ /* Help or descriptive text for the entire group */
35
+ description?: string;
36
+ /* Group-level validation error message */
37
+ error?: string;
38
+ /* Support single selection or multiple checkbox checks */
39
+ type?: "single" | "multiple";
40
+ /* Controlled selection value(s) */
41
+ value?: string | string[];
42
+ /* Default initial selection value(s) */
43
+ defaultValue?: string | string[];
44
+ /* Callback triggered on selection change */
45
+ onChange?: (value: any) => void;
46
+ /* Array of checkbox option objects */
47
+ options: CheckboxOption[];
48
+ /* Visual width class for the label area */
49
+ labelWidth?: string;
50
+ /* Horizontal alignment of the group label */
51
+ labelAlign?: "left" | "center" | "right";
52
+ /* Group-level bullets or numerical index prefix markers */
53
+ indicator?: "dots" | "numbers" | React.ReactNode;
54
+ }
55
+
56
+ /*
57
+ * ============================================================================
58
+ * CheckboxGroup Component
59
+ * ============================================================================
60
+ */
61
+
62
+ export const CheckboxGroup = ({
63
+ label,
64
+ description,
65
+ error,
66
+ type = "multiple",
67
+ value,
68
+ defaultValue,
69
+ onChange,
70
+ options = [],
71
+ className,
72
+ labelWidth = "w-full",
73
+ labelAlign = "left",
74
+ indicator,
75
+ ...props
76
+ }: CheckboxGroupProps) => {
77
+ /* Pure Controlled State: Source value directly from parent-provided props */
78
+ const activeValue = value !== undefined ? value : (defaultValue || (type === "multiple" ? [] : ""));
79
+
80
+ /* Appends/removes option keys in array for multi-mode, or toggles value for single-mode */
81
+ const handleCheckboxChange = (optionValue: string) => {
82
+ let newValue: string | string[];
83
+
84
+ if (type === "single") {
85
+ newValue = activeValue === optionValue ? "" : optionValue;
86
+ } else {
87
+ const currentValues = Array.isArray(activeValue) ? activeValue : [];
88
+ newValue = currentValues.includes(optionValue)
89
+ ? currentValues.filter(v => v !== optionValue)
90
+ : [...currentValues, optionValue];
91
+ }
92
+
93
+ onChange?.(newValue);
94
+ };
95
+
96
+ return (
97
+ <div className={cn("flex w-full flex-col gap-3", className)}>
98
+ {/* Group Label Area (contains label and description) */}
99
+ {(label || description) && (
100
+ <div className={cn("flex flex-col shrink-0", labelWidth)}>
101
+ <div
102
+ className={cn(
103
+ "flex flex-col",
104
+ labelAlign === "left" && "items-start text-left",
105
+ labelAlign === "right" && "items-end text-right",
106
+ labelAlign === "center" && "items-center text-center",
107
+ )}
108
+ >
109
+ {label && (
110
+ <span className="tracking-tight uppercase text-sm font-bold text-black">
111
+ {label}
112
+ </span>
113
+ )}
114
+ {description && (
115
+ <span className="mt-0.5 leading-tight text-[11px] font-medium text-black">
116
+ {description}
117
+ </span>
118
+ )}
119
+ </div>
120
+ </div>
121
+ )}
122
+
123
+ {/* Checkbox Options Area */}
124
+ <div className="flex-1 flex flex-col gap-1.5">
125
+ <div className="flex gap-4 flex-col">
126
+ {options.map((option, index) => {
127
+ const isChecked =
128
+ type === "single"
129
+ ? activeValue === option.value
130
+ : Array.isArray(activeValue) &&
131
+ activeValue.includes(option.value);
132
+
133
+ /* Resolves bullet, number index, or custom indicators */
134
+ const optIndicator = option.indicator || indicator;
135
+ let indicatorNode = null;
136
+ if (optIndicator === "bullet" || optIndicator === "dots") {
137
+ indicatorNode = (
138
+ <div className="w-1.5 h-1.5 rounded-full bg-white shrink-0 mt-2" />
139
+ );
140
+ } else if (
141
+ optIndicator === "number" ||
142
+ optIndicator === "numbers"
143
+ ) {
144
+ indicatorNode = (
145
+ <span className="text-sm font-bold shrink-0 mt-0.5 w-4 text-center text-gray-500">
146
+ {index + 1}.
147
+ </span>
148
+ );
149
+ } else if (optIndicator) {
150
+ indicatorNode = (
151
+ <div className="shrink-0 mt-0.5 flex items-center justify-center text-sm font-medium text-gray-500">
152
+ {optIndicator}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ return (
158
+ <div
159
+ key={option.id || option.value || index}
160
+ className="flex gap-3 items-start"
161
+ >
162
+ {indicatorNode}
163
+ <Checkbox
164
+ id={option.id}
165
+ label={option.label}
166
+ description={option.description}
167
+ disabled={option.disabled}
168
+ checked={isChecked}
169
+ onChange={() => handleCheckboxChange(option.value)}
170
+ className="shrink-0 flex-1"
171
+ {...props}
172
+ />
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+
178
+ {/* Group Validation Error Message (displayed only once) */}
179
+ {error && (
180
+ <span className="text-[10px] font-medium mt-1.5 ml-1 block animate-in fade-in slide-in-from-top-1 text-red-500">
181
+ {error}
182
+ </span>
183
+ )}
184
+ </div>
185
+ </div>
186
+ );
187
+ };
188
+
189
+ /*
190
+ * ============================================================================
191
+ * State Lifting Explanation & Usage Guide
192
+ * ============================================================================
193
+ *
194
+ * 1. What state was lifted up?
195
+ * The selection states (`internalValue` single string or array of strings)
196
+ * have been completely lifted out of the local component scope. The local
197
+ * `useState` hook and lifecycle `useEffect` synchronization logic have
198
+ * been completely removed.
199
+ *
200
+ * 2. How to work with this pure controlled component (Standard React State):
201
+ * Define an array state and its setter in the parent component:
202
+ *
203
+ * const [selectedItems, setSelectedItems] = useState(["design"]);
204
+ *
205
+ * Then render the component:
206
+ * <CheckboxGroup
207
+ * label="Interests"
208
+ * type="multiple"
209
+ * value={selectedItems}
210
+ * onChange={setSelectedItems}
211
+ * options={[
212
+ * { label: "Technology", value: "technology" },
213
+ * { label: "Design", value: "design" }
214
+ * ]}
215
+ * />
216
+ *
217
+ * 3. React Hook Form Integration:
218
+ * When using with React Hook Form, wrap this component inside a <Controller>:
219
+ *
220
+ * <Controller
221
+ * name="interests"
222
+ * control={control}
223
+ * render={({ field }) => (
224
+ * <CheckboxGroup
225
+ * label="Interests"
226
+ * value={field.value}
227
+ * onChange={field.onChange}
228
+ * options={[
229
+ * { label: "Technology", value: "technology" },
230
+ * { label: "Design", value: "design" }
231
+ * ]}
232
+ * />
233
+ * )}
234
+ * />
235
+ */
@@ -0,0 +1,148 @@
1
+ import React, { useState } from "react";
2
+ import { Check } from "lucide-react";
3
+ import { cn } from "@/utils/cn";
4
+
5
+ /*
6
+ * ============================================================================
7
+ * Types & Interfaces
8
+ * ============================================================================
9
+ */
10
+
11
+ /* Prop configuration for the Checkbox component */
12
+ export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
13
+ /* Title label of the checkbox */
14
+ label?: string;
15
+ /* Help or descriptive text for the checkbox */
16
+ description?: string;
17
+ /* Validation error message specific to this checkbox */
18
+ error?: string;
19
+ /* Controls placement of label relative to the checkbox square */
20
+ labelPlacement?: "top" | "left" | "right";
21
+ /* Sizing parameter for the label container width */
22
+ labelWidth?: string;
23
+ /* Horizontal alignment of the text label */
24
+ "labelAlign-X"?: "left" | "center" | "right";
25
+ /* Vertical alignment of the text label */
26
+ "labelAlign-Y"?: "top" | "middle" | "bottom";
27
+ /* Visual border radius variant */
28
+ variant?: "rounded" | "curved" | "square" | "circle";
29
+ /* Callback triggered on selection change */
30
+ onChange?: (checked: boolean) => void;
31
+ }
32
+
33
+ /*
34
+ * ============================================================================
35
+ * Checkbox Component
36
+ * ============================================================================
37
+ */
38
+
39
+ export const Checkbox = ({
40
+ label,
41
+ description,
42
+ error,
43
+ labelPlacement = "left",
44
+ labelWidth,
45
+ "labelAlign-X": labelAlignX,
46
+ "labelAlign-Y": labelAlignY = "middle",
47
+ variant = "rounded",
48
+ onChange,
49
+ className,
50
+ id,
51
+ ...props
52
+ }: CheckboxProps) => {
53
+ const checkboxId = id || React.useId();
54
+
55
+ /* Managed checked selection states supporting controlled and uncontrolled modes */
56
+ const [internalChecked, setInternalChecked] = useState(props.defaultChecked || false);
57
+ const isControlled = props.checked !== undefined;
58
+ const checked = isControlled ? props.checked : internalChecked;
59
+
60
+ /* Form event delegation and local state synchrony on changes */
61
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
62
+ if (props.disabled) return;
63
+ const newChecked = e.target.checked;
64
+ if (!isControlled) setInternalChecked(newChecked);
65
+ onChange?.(newChecked);
66
+ };
67
+
68
+ const isSideLabel = labelPlacement === "left" || labelPlacement === "right";
69
+ const xAlignment = labelAlignX || (labelPlacement === "left" ? "left" : labelPlacement === "right" ? "right" : "left");
70
+ const yAlignmentClass = labelAlignY === "top" ? "items-start" : labelAlignY === "bottom" ? "items-end" : "items-center";
71
+ const borderRadius = variant === "circle" ? "rounded-full" : variant === "square" ? "rounded-none" : variant === "curved" ? "rounded-md" : "rounded-lg";
72
+
73
+ return (
74
+ <div className={cn("flex flex-col gap-1.5", className)}>
75
+ {/* Checkbox Input and Selection Wrapper Label */}
76
+ <label
77
+ htmlFor={checkboxId}
78
+ className={cn(
79
+ "group flex cursor-pointer select-none transition-all duration-200 gap-4",
80
+ labelPlacement === "top" && "flex-col",
81
+ /* labelPlacement 'left' aligns the checkbox dot to the extreme right end */
82
+ labelPlacement === "left" && cn("flex-row-reverse justify-between w-full", yAlignmentClass),
83
+ labelPlacement === "right" && cn("flex-row justify-start", yAlignmentClass),
84
+ props.disabled && "opacity-50 cursor-not-allowed"
85
+ )}
86
+ >
87
+ <div className="relative flex items-center shrink-0">
88
+ <input
89
+ {...props}
90
+ type="checkbox"
91
+ id={checkboxId}
92
+ className="peer sr-only"
93
+ checked={checked}
94
+ onChange={handleChange}
95
+ />
96
+ {/* Outer checkbox border and container */}
97
+ <div
98
+ className={cn(
99
+ "w-5 h-5 transition-all duration-200 flex items-center justify-center border-[1.5px] border-black",
100
+ borderRadius,
101
+ checked
102
+ ? "bg-white scale-110"
103
+ : "bg-white",
104
+ "peer-focus-visible:ring-4 peer-focus-visible:ring-sky-500/10 peer-focus-visible:border-sky-500"
105
+ )}
106
+ >
107
+ {/* Checked checkmark icon indicator */}
108
+ <Check
109
+ size={12}
110
+ strokeWidth={4}
111
+ className={cn(
112
+ "transition-all duration-200 transform",
113
+ checked ? "scale-100 opacity-100" : "scale-50 opacity-0",
114
+ "text-black"
115
+ )}
116
+ />
117
+ </div>
118
+ </div>
119
+
120
+ {/* Text Area (Option labels and descriptive help texts) */}
121
+ {(label || description) && (
122
+ <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")}>
123
+ {label && (
124
+ <span className="whitespace-normal break-words w-full capitalize text-sm font-medium text-black">
125
+ {label}
126
+ {props.required && (
127
+ <span className="ml-1 font-black text-red-500">*</span>
128
+ )}
129
+ </span>
130
+ )}
131
+ {description && (
132
+ <span className="leading-tight whitespace-normal break-words w-full text-xs text-black">
133
+ {description}
134
+ </span>
135
+ )}
136
+ </div>
137
+ )}
138
+ </label>
139
+
140
+ {/* Option Validation Error Message */}
141
+ {error && (
142
+ <span className="text-xs font-medium ml-1 italic tracking-tight animate-in fade-in slide-in-from-top-1 text-red-500">
143
+ {error}
144
+ </span>
145
+ )}
146
+ </div>
147
+ );
148
+ };