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,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
|
+
};
|