@lub-crm/forms 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/README.md +298 -0
- package/dist/lub-forms.css +1 -0
- package/dist/lub-forms.es.js +5848 -0
- package/dist/lub-forms.es.js.map +1 -0
- package/dist/lub-forms.standalone.js +10 -0
- package/dist/lub-forms.standalone.js.map +1 -0
- package/dist/lub-forms.umd.js +227 -0
- package/dist/lub-forms.umd.js.map +1 -0
- package/package.json +68 -0
- package/src/api/client.ts +115 -0
- package/src/api/index.ts +2 -0
- package/src/api/types.ts +202 -0
- package/src/core/FormProvider.tsx +228 -0
- package/src/core/FormRenderer.tsx +134 -0
- package/src/core/LubForm.tsx +476 -0
- package/src/core/StepManager.tsx +199 -0
- package/src/core/index.ts +4 -0
- package/src/embed.ts +188 -0
- package/src/fields/CheckboxField.tsx +62 -0
- package/src/fields/CheckboxGroupField.tsx +57 -0
- package/src/fields/CountryField.tsx +43 -0
- package/src/fields/DateField.tsx +33 -0
- package/src/fields/DateTimeField.tsx +33 -0
- package/src/fields/DividerField.tsx +16 -0
- package/src/fields/FieldWrapper.tsx +60 -0
- package/src/fields/FileField.tsx +45 -0
- package/src/fields/HiddenField.tsx +18 -0
- package/src/fields/HtmlField.tsx +17 -0
- package/src/fields/NumberField.tsx +39 -0
- package/src/fields/RadioField.tsx +57 -0
- package/src/fields/RecaptchaField.tsx +137 -0
- package/src/fields/SelectField.tsx +49 -0
- package/src/fields/StateField.tsx +84 -0
- package/src/fields/TextField.tsx +51 -0
- package/src/fields/TextareaField.tsx +37 -0
- package/src/fields/TimeField.tsx +33 -0
- package/src/fields/index.ts +84 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useConditionalLogic.ts +59 -0
- package/src/hooks/useFormApi.ts +118 -0
- package/src/hooks/useFormDesign.ts +48 -0
- package/src/hooks/useMultiStep.ts +98 -0
- package/src/index.ts +101 -0
- package/src/main.tsx +40 -0
- package/src/styles/index.css +707 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/countries.ts +163 -0
- package/src/utils/css-variables.ts +63 -0
- package/src/utils/index.ts +3 -0
- package/src/validation/conditional.ts +170 -0
- package/src/validation/index.ts +2 -0
- package/src/validation/schema-builder.ts +327 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
import type { FormStep } from "@/api/types";
|
|
4
|
+
|
|
5
|
+
interface StepManagerProps {
|
|
6
|
+
steps: FormStep[];
|
|
7
|
+
currentStep: number;
|
|
8
|
+
onStepClick?: (stepIndex: number) => void;
|
|
9
|
+
allowStepNavigation?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StepManager({
|
|
13
|
+
steps,
|
|
14
|
+
currentStep,
|
|
15
|
+
onStepClick,
|
|
16
|
+
allowStepNavigation = false,
|
|
17
|
+
}: StepManagerProps) {
|
|
18
|
+
if (steps.length <= 1) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="lub-form__steps" role="navigation" aria-label="Form steps">
|
|
22
|
+
<ol className="lub-form__steps-list">
|
|
23
|
+
{steps.map((step, index) => {
|
|
24
|
+
const isActive = index === currentStep;
|
|
25
|
+
const isCompleted = index < currentStep;
|
|
26
|
+
const isClickable = allowStepNavigation && (isCompleted || isActive);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<li
|
|
30
|
+
key={step.id}
|
|
31
|
+
className={cn(
|
|
32
|
+
"lub-form__step",
|
|
33
|
+
isActive && "lub-form__step--active",
|
|
34
|
+
isCompleted && "lub-form__step--completed",
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{isClickable ? (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
className="lub-form__step-button"
|
|
41
|
+
onClick={() => onStepClick?.(index)}
|
|
42
|
+
aria-current={isActive ? "step" : undefined}
|
|
43
|
+
>
|
|
44
|
+
<span className="lub-form__step-number">{index + 1}</span>
|
|
45
|
+
<span className="lub-form__step-name">{step.name}</span>
|
|
46
|
+
</button>
|
|
47
|
+
) : (
|
|
48
|
+
<span
|
|
49
|
+
className="lub-form__step-indicator"
|
|
50
|
+
aria-current={isActive ? "step" : undefined}
|
|
51
|
+
>
|
|
52
|
+
<span className="lub-form__step-number">
|
|
53
|
+
{isCompleted ? <CheckIcon /> : index + 1}
|
|
54
|
+
</span>
|
|
55
|
+
<span className="lub-form__step-name">{step.name}</span>
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
{index < steps.length - 1 && (
|
|
59
|
+
<span
|
|
60
|
+
className={cn(
|
|
61
|
+
"lub-form__step-connector",
|
|
62
|
+
isCompleted && "lub-form__step-connector--completed",
|
|
63
|
+
)}
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</li>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</ol>
|
|
71
|
+
{steps[currentStep]?.description && (
|
|
72
|
+
<p className="lub-form__step-description">
|
|
73
|
+
{steps[currentStep].description}
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function CheckIcon() {
|
|
81
|
+
return (
|
|
82
|
+
<svg
|
|
83
|
+
className="lub-form__check-icon"
|
|
84
|
+
viewBox="0 0 20 20"
|
|
85
|
+
fill="currentColor"
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
>
|
|
88
|
+
<path
|
|
89
|
+
fillRule="evenodd"
|
|
90
|
+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
91
|
+
clipRule="evenodd"
|
|
92
|
+
/>
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Step navigation buttons
|
|
98
|
+
interface StepNavigationProps {
|
|
99
|
+
currentStep: number;
|
|
100
|
+
totalSteps: number;
|
|
101
|
+
onNext: () => void;
|
|
102
|
+
onPrev: () => void;
|
|
103
|
+
isNextDisabled?: boolean;
|
|
104
|
+
isPrevDisabled?: boolean;
|
|
105
|
+
isLastStep?: boolean;
|
|
106
|
+
isSubmitting?: boolean;
|
|
107
|
+
submitButtonText?: string;
|
|
108
|
+
nextButtonText?: string;
|
|
109
|
+
prevButtonText?: string;
|
|
110
|
+
children?: ReactNode;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function StepNavigation({
|
|
114
|
+
currentStep,
|
|
115
|
+
totalSteps,
|
|
116
|
+
onNext,
|
|
117
|
+
onPrev,
|
|
118
|
+
isNextDisabled = false,
|
|
119
|
+
isPrevDisabled = false,
|
|
120
|
+
isLastStep = false,
|
|
121
|
+
isSubmitting = false,
|
|
122
|
+
submitButtonText = "Submit",
|
|
123
|
+
nextButtonText = "Next",
|
|
124
|
+
prevButtonText = "Back",
|
|
125
|
+
children,
|
|
126
|
+
}: StepNavigationProps) {
|
|
127
|
+
const showPrev = currentStep > 0;
|
|
128
|
+
const showNext = !isLastStep;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="lub-form__navigation">
|
|
132
|
+
{showPrev && (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="lub-form__button lub-form__button--secondary"
|
|
136
|
+
onClick={onPrev}
|
|
137
|
+
disabled={isPrevDisabled || isSubmitting}
|
|
138
|
+
>
|
|
139
|
+
{prevButtonText}
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
<div className="lub-form__navigation-spacer" />
|
|
143
|
+
{showNext ? (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
className="lub-form__button lub-form__button--primary"
|
|
147
|
+
onClick={onNext}
|
|
148
|
+
disabled={isNextDisabled || isSubmitting}
|
|
149
|
+
>
|
|
150
|
+
{nextButtonText}
|
|
151
|
+
</button>
|
|
152
|
+
) : (
|
|
153
|
+
(children ?? (
|
|
154
|
+
<button
|
|
155
|
+
type="submit"
|
|
156
|
+
className="lub-form__button lub-form__button--primary"
|
|
157
|
+
disabled={isSubmitting}
|
|
158
|
+
>
|
|
159
|
+
{isSubmitting ? (
|
|
160
|
+
<span className="lub-form__button-loading">
|
|
161
|
+
<LoadingSpinner />
|
|
162
|
+
Submitting...
|
|
163
|
+
</span>
|
|
164
|
+
) : (
|
|
165
|
+
submitButtonText
|
|
166
|
+
)}
|
|
167
|
+
</button>
|
|
168
|
+
))
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function LoadingSpinner() {
|
|
175
|
+
return (
|
|
176
|
+
<svg
|
|
177
|
+
className="lub-form__spinner"
|
|
178
|
+
viewBox="0 0 24 24"
|
|
179
|
+
fill="none"
|
|
180
|
+
aria-hidden="true"
|
|
181
|
+
>
|
|
182
|
+
<circle
|
|
183
|
+
className="lub-form__spinner-track"
|
|
184
|
+
cx="12"
|
|
185
|
+
cy="12"
|
|
186
|
+
r="10"
|
|
187
|
+
stroke="currentColor"
|
|
188
|
+
strokeWidth="3"
|
|
189
|
+
/>
|
|
190
|
+
<path
|
|
191
|
+
className="lub-form__spinner-head"
|
|
192
|
+
d="M12 2a10 10 0 0 1 10 10"
|
|
193
|
+
stroke="currentColor"
|
|
194
|
+
strokeWidth="3"
|
|
195
|
+
strokeLinecap="round"
|
|
196
|
+
/>
|
|
197
|
+
</svg>
|
|
198
|
+
);
|
|
199
|
+
}
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UMD/IIFE entry point for script tag embedding
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
*
|
|
6
|
+
* <!-- Auto-mount via data attributes -->
|
|
7
|
+
* <script src="https://forms.lub.com/v1/lub-forms.standalone.js"></script>
|
|
8
|
+
* <link rel="stylesheet" href="https://forms.lub.com/v1/lub-forms.css">
|
|
9
|
+
* <div data-lub-form-id="abc123" data-lub-base-url="https://api.lub.com"></div>
|
|
10
|
+
*
|
|
11
|
+
* <!-- Manual render -->
|
|
12
|
+
* <script>
|
|
13
|
+
* LubForms.render('abc123', 'container-id', {
|
|
14
|
+
* baseUrl: 'https://api.lub.com',
|
|
15
|
+
* onSuccess: (data) => console.log('Success!', data),
|
|
16
|
+
* onError: (err) => console.error('Error:', err),
|
|
17
|
+
* });
|
|
18
|
+
* </script>
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createElement } from "react";
|
|
22
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
23
|
+
import { LubForm, type LubFormProps } from "./core/LubForm";
|
|
24
|
+
import type { SubmitFormResponse, ApiError, FormDesign } from "./api/types";
|
|
25
|
+
|
|
26
|
+
// Import styles for standalone bundle
|
|
27
|
+
import "./styles/index.css";
|
|
28
|
+
|
|
29
|
+
// Store roots for cleanup
|
|
30
|
+
const roots = new Map<string, Root>();
|
|
31
|
+
|
|
32
|
+
// Render options type
|
|
33
|
+
interface RenderOptions {
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
onSuccess?: (data: SubmitFormResponse) => void;
|
|
36
|
+
onError?: (error: ApiError) => void;
|
|
37
|
+
onValidationError?: (errors: Record<string, string>) => void;
|
|
38
|
+
onStepChange?: (step: number, total: number) => void;
|
|
39
|
+
className?: string;
|
|
40
|
+
designOverrides?: Partial<FormDesign>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render a form into a container element
|
|
45
|
+
*/
|
|
46
|
+
function render(
|
|
47
|
+
formId: string,
|
|
48
|
+
containerOrId: string | HTMLElement,
|
|
49
|
+
options: RenderOptions = {},
|
|
50
|
+
): { unmount: () => void } {
|
|
51
|
+
const container =
|
|
52
|
+
typeof containerOrId === "string"
|
|
53
|
+
? document.getElementById(containerOrId)
|
|
54
|
+
: containerOrId;
|
|
55
|
+
|
|
56
|
+
if (!container) {
|
|
57
|
+
console.error(`[LubForms] Container not found: ${containerOrId}`);
|
|
58
|
+
return { unmount: () => {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Unmount existing root if present
|
|
62
|
+
const existingRoot = roots.get(container.id || formId);
|
|
63
|
+
if (existingRoot) {
|
|
64
|
+
existingRoot.unmount();
|
|
65
|
+
roots.delete(container.id || formId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Create new root and render
|
|
69
|
+
const root = createRoot(container);
|
|
70
|
+
roots.set(container.id || formId, root);
|
|
71
|
+
|
|
72
|
+
const props: LubFormProps = {
|
|
73
|
+
formId,
|
|
74
|
+
baseUrl: options.baseUrl || "",
|
|
75
|
+
onSuccess: options.onSuccess,
|
|
76
|
+
onError: options.onError,
|
|
77
|
+
onValidationError: options.onValidationError,
|
|
78
|
+
onStepChange: options.onStepChange,
|
|
79
|
+
className: options.className,
|
|
80
|
+
designOverrides: options.designOverrides,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
root.render(createElement(LubForm, props));
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
unmount: () => {
|
|
87
|
+
root.unmount();
|
|
88
|
+
roots.delete(container.id || formId);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Auto-mount forms from data attributes
|
|
95
|
+
*/
|
|
96
|
+
function autoMount(): void {
|
|
97
|
+
const containers =
|
|
98
|
+
document.querySelectorAll<HTMLElement>("[data-lub-form-id]");
|
|
99
|
+
|
|
100
|
+
containers.forEach((container) => {
|
|
101
|
+
const formId = container.dataset.lubFormId;
|
|
102
|
+
if (!formId) return;
|
|
103
|
+
|
|
104
|
+
// Skip if already mounted
|
|
105
|
+
if (container.dataset.lubMounted === "true") return;
|
|
106
|
+
|
|
107
|
+
const options: RenderOptions = {
|
|
108
|
+
baseUrl: container.dataset.lubBaseUrl || "",
|
|
109
|
+
className: container.dataset.lubClass,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Handle callbacks via global functions
|
|
113
|
+
const onSuccessName = container.dataset.lubOnSuccess;
|
|
114
|
+
if (
|
|
115
|
+
onSuccessName &&
|
|
116
|
+
typeof window[onSuccessName as keyof Window] === "function"
|
|
117
|
+
) {
|
|
118
|
+
options.onSuccess = window[onSuccessName as keyof Window] as (
|
|
119
|
+
data: SubmitFormResponse,
|
|
120
|
+
) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const onErrorName = container.dataset.lubOnError;
|
|
124
|
+
if (
|
|
125
|
+
onErrorName &&
|
|
126
|
+
typeof window[onErrorName as keyof Window] === "function"
|
|
127
|
+
) {
|
|
128
|
+
options.onError = window[onErrorName as keyof Window] as (
|
|
129
|
+
error: ApiError,
|
|
130
|
+
) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
render(formId, container, options);
|
|
134
|
+
container.dataset.lubMounted = "true";
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Unmount a form by container ID
|
|
140
|
+
*/
|
|
141
|
+
function unmount(containerId: string): void {
|
|
142
|
+
const root = roots.get(containerId);
|
|
143
|
+
if (root) {
|
|
144
|
+
root.unmount();
|
|
145
|
+
roots.delete(containerId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Unmount all forms
|
|
151
|
+
*/
|
|
152
|
+
function unmountAll(): void {
|
|
153
|
+
roots.forEach((root) => root.unmount());
|
|
154
|
+
roots.clear();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Initialize LubForms (call autoMount on DOM ready)
|
|
159
|
+
*/
|
|
160
|
+
function init(): void {
|
|
161
|
+
if (document.readyState === "loading") {
|
|
162
|
+
document.addEventListener("DOMContentLoaded", autoMount);
|
|
163
|
+
} else {
|
|
164
|
+
autoMount();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Export for UMD/global
|
|
169
|
+
const LubForms = {
|
|
170
|
+
render,
|
|
171
|
+
autoMount,
|
|
172
|
+
unmount,
|
|
173
|
+
unmountAll,
|
|
174
|
+
init,
|
|
175
|
+
// Re-export main component for advanced usage
|
|
176
|
+
LubForm,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Auto-initialize when script loads
|
|
180
|
+
init();
|
|
181
|
+
|
|
182
|
+
// Expose on window for UMD
|
|
183
|
+
if (typeof window !== "undefined") {
|
|
184
|
+
(window as unknown as { LubForms: typeof LubForms }).LubForms = LubForms;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default LubForms;
|
|
188
|
+
export { render, autoMount, unmount, unmountAll, init, LubForm };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useFormContext } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
import { cn } from "@/utils/cn";
|
|
4
|
+
|
|
5
|
+
interface CheckboxFieldProps {
|
|
6
|
+
field: FormField;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function CheckboxField({ field }: CheckboxFieldProps) {
|
|
10
|
+
const { register, formState } = useFormContext();
|
|
11
|
+
const error = formState.errors[field.name];
|
|
12
|
+
const hasError = !!error;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className={cn(
|
|
17
|
+
"lub-form__field-wrapper",
|
|
18
|
+
hasError && "lub-form__field-wrapper--error",
|
|
19
|
+
)}
|
|
20
|
+
>
|
|
21
|
+
<label className="lub-form__checkbox-label">
|
|
22
|
+
<input
|
|
23
|
+
id={field.name}
|
|
24
|
+
type="checkbox"
|
|
25
|
+
className={cn(
|
|
26
|
+
"lub-form__checkbox",
|
|
27
|
+
hasError && "lub-form__checkbox--error",
|
|
28
|
+
)}
|
|
29
|
+
aria-invalid={hasError}
|
|
30
|
+
aria-describedby={
|
|
31
|
+
hasError
|
|
32
|
+
? `${field.name}-error`
|
|
33
|
+
: field.help_text
|
|
34
|
+
? `${field.name}-help`
|
|
35
|
+
: undefined
|
|
36
|
+
}
|
|
37
|
+
{...register(field.name)}
|
|
38
|
+
/>
|
|
39
|
+
<span className="lub-form__checkbox-text">
|
|
40
|
+
{field.label}
|
|
41
|
+
{field.required && (
|
|
42
|
+
<span className="lub-form__required-indicator" aria-hidden="true">
|
|
43
|
+
*
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
46
|
+
</span>
|
|
47
|
+
</label>
|
|
48
|
+
|
|
49
|
+
{field.help_text && !hasError && (
|
|
50
|
+
<p className="lub-form__help-text" id={`${field.name}-help`}>
|
|
51
|
+
{field.help_text}
|
|
52
|
+
</p>
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
{hasError && (
|
|
56
|
+
<p className="lub-form__error" id={`${field.name}-error`} role="alert">
|
|
57
|
+
{error.message as string}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useFormContext } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
import { FieldWrapper } from "./FieldWrapper";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
interface CheckboxGroupFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CheckboxGroupField({ field }: CheckboxGroupFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
const options = field.options?.options ?? [];
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<FieldWrapper field={field}>
|
|
19
|
+
<div
|
|
20
|
+
className="lub-form__checkbox-group"
|
|
21
|
+
role="group"
|
|
22
|
+
aria-labelledby={`${field.name}-label`}
|
|
23
|
+
>
|
|
24
|
+
{options.map((option) => (
|
|
25
|
+
<label key={option.value} className="lub-form__checkbox-label">
|
|
26
|
+
<input
|
|
27
|
+
type="checkbox"
|
|
28
|
+
className={cn(
|
|
29
|
+
"lub-form__checkbox",
|
|
30
|
+
hasError && "lub-form__checkbox--error",
|
|
31
|
+
)}
|
|
32
|
+
value={option.value}
|
|
33
|
+
{...register(field.name)}
|
|
34
|
+
/>
|
|
35
|
+
<span className="lub-form__checkbox-text">{option.label}</span>
|
|
36
|
+
</label>
|
|
37
|
+
))}
|
|
38
|
+
{field.options?.allow_other && (
|
|
39
|
+
<label className="lub-form__checkbox-label">
|
|
40
|
+
<input
|
|
41
|
+
type="checkbox"
|
|
42
|
+
className={cn(
|
|
43
|
+
"lub-form__checkbox",
|
|
44
|
+
hasError && "lub-form__checkbox--error",
|
|
45
|
+
)}
|
|
46
|
+
value="__other__"
|
|
47
|
+
{...register(field.name)}
|
|
48
|
+
/>
|
|
49
|
+
<span className="lub-form__checkbox-text">
|
|
50
|
+
{field.options.other_label || "Other"}
|
|
51
|
+
</span>
|
|
52
|
+
</label>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</FieldWrapper>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useFormContext } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
import { FieldWrapper } from "./FieldWrapper";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
import { countries } from "@/utils/countries";
|
|
6
|
+
|
|
7
|
+
interface CountryFieldProps {
|
|
8
|
+
field: FormField;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CountryField({ field }: CountryFieldProps) {
|
|
12
|
+
const { register, formState } = useFormContext();
|
|
13
|
+
const error = formState.errors[field.name];
|
|
14
|
+
const hasError = !!error;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<FieldWrapper field={field}>
|
|
18
|
+
<select
|
|
19
|
+
id={field.name}
|
|
20
|
+
className={cn(
|
|
21
|
+
"lub-form__select",
|
|
22
|
+
hasError && "lub-form__select--error",
|
|
23
|
+
)}
|
|
24
|
+
aria-invalid={hasError}
|
|
25
|
+
aria-describedby={
|
|
26
|
+
hasError
|
|
27
|
+
? `${field.name}-error`
|
|
28
|
+
: field.help_text
|
|
29
|
+
? `${field.name}-help`
|
|
30
|
+
: undefined
|
|
31
|
+
}
|
|
32
|
+
{...register(field.name)}
|
|
33
|
+
>
|
|
34
|
+
<option value="">{field.placeholder || "Select a country..."}</option>
|
|
35
|
+
{countries.map((country) => (
|
|
36
|
+
<option key={country.code} value={country.code}>
|
|
37
|
+
{country.name}
|
|
38
|
+
</option>
|
|
39
|
+
))}
|
|
40
|
+
</select>
|
|
41
|
+
</FieldWrapper>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useFormContext } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
import { FieldWrapper } from "./FieldWrapper";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
interface DateFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DateField({ field }: DateFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<FieldWrapper field={field}>
|
|
17
|
+
<input
|
|
18
|
+
id={field.name}
|
|
19
|
+
type="date"
|
|
20
|
+
className={cn("lub-form__input", hasError && "lub-form__input--error")}
|
|
21
|
+
aria-invalid={hasError}
|
|
22
|
+
aria-describedby={
|
|
23
|
+
hasError
|
|
24
|
+
? `${field.name}-error`
|
|
25
|
+
: field.help_text
|
|
26
|
+
? `${field.name}-help`
|
|
27
|
+
: undefined
|
|
28
|
+
}
|
|
29
|
+
{...register(field.name)}
|
|
30
|
+
/>
|
|
31
|
+
</FieldWrapper>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useFormContext } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
import { FieldWrapper } from "./FieldWrapper";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
interface DateTimeFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DateTimeField({ field }: DateTimeFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<FieldWrapper field={field}>
|
|
17
|
+
<input
|
|
18
|
+
id={field.name}
|
|
19
|
+
type="datetime-local"
|
|
20
|
+
className={cn("lub-form__input", hasError && "lub-form__input--error")}
|
|
21
|
+
aria-invalid={hasError}
|
|
22
|
+
aria-describedby={
|
|
23
|
+
hasError
|
|
24
|
+
? `${field.name}-error`
|
|
25
|
+
: field.help_text
|
|
26
|
+
? `${field.name}-help`
|
|
27
|
+
: undefined
|
|
28
|
+
}
|
|
29
|
+
{...register(field.name)}
|
|
30
|
+
/>
|
|
31
|
+
</FieldWrapper>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FormField } from "@/api/types";
|
|
2
|
+
|
|
3
|
+
interface DividerFieldProps {
|
|
4
|
+
field: FormField;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function DividerField({ field }: DividerFieldProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="lub-form__divider">
|
|
10
|
+
<hr className="lub-form__divider-line" />
|
|
11
|
+
{field.label && (
|
|
12
|
+
<span className="lub-form__divider-label">{field.label}</span>
|
|
13
|
+
)}
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useFormContext } from "react-hook-form";
|
|
3
|
+
import type { FormField } from "@/api/types";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
|
|
6
|
+
interface FieldWrapperProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
hideLabel?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FieldWrapper({
|
|
13
|
+
field,
|
|
14
|
+
children,
|
|
15
|
+
hideLabel = false,
|
|
16
|
+
}: FieldWrapperProps) {
|
|
17
|
+
const { formState } = useFormContext();
|
|
18
|
+
const error = formState.errors[field.name];
|
|
19
|
+
const hasError = !!error;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className={cn(
|
|
24
|
+
"lub-form__field-wrapper",
|
|
25
|
+
hasError && "lub-form__field-wrapper--error",
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
{!hideLabel && field.label && (
|
|
29
|
+
<label
|
|
30
|
+
htmlFor={field.name}
|
|
31
|
+
className={cn(
|
|
32
|
+
"lub-form__label",
|
|
33
|
+
field.required && "lub-form__label--required",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{field.label}
|
|
37
|
+
{field.required && (
|
|
38
|
+
<span className="lub-form__required-indicator" aria-hidden="true">
|
|
39
|
+
*
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
</label>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
{children}
|
|
46
|
+
|
|
47
|
+
{field.help_text && !hasError && (
|
|
48
|
+
<p className="lub-form__help-text" id={`${field.name}-help`}>
|
|
49
|
+
{field.help_text}
|
|
50
|
+
</p>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
{hasError && (
|
|
54
|
+
<p className="lub-form__error" id={`${field.name}-error`} role="alert">
|
|
55
|
+
{error.message as string}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|