@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,45 @@
|
|
|
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 FileFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function FileField({ field }: FileFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
const rules = field.validation_rules;
|
|
16
|
+
const acceptTypes = rules?.allowed_types?.join(",");
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<FieldWrapper field={field}>
|
|
20
|
+
<input
|
|
21
|
+
id={field.name}
|
|
22
|
+
type="file"
|
|
23
|
+
className={cn(
|
|
24
|
+
"lub-form__file-input",
|
|
25
|
+
hasError && "lub-form__file-input--error",
|
|
26
|
+
)}
|
|
27
|
+
accept={acceptTypes}
|
|
28
|
+
aria-invalid={hasError}
|
|
29
|
+
aria-describedby={
|
|
30
|
+
hasError
|
|
31
|
+
? `${field.name}-error`
|
|
32
|
+
: field.help_text
|
|
33
|
+
? `${field.name}-help`
|
|
34
|
+
: undefined
|
|
35
|
+
}
|
|
36
|
+
{...register(field.name)}
|
|
37
|
+
/>
|
|
38
|
+
{rules?.max_file_size && (
|
|
39
|
+
<p className="lub-form__file-hint">
|
|
40
|
+
Max file size: {rules.max_file_size}MB
|
|
41
|
+
</p>
|
|
42
|
+
)}
|
|
43
|
+
</FieldWrapper>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useFormContext } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
|
|
4
|
+
interface HiddenFieldProps {
|
|
5
|
+
field: FormField;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function HiddenField({ field }: HiddenFieldProps) {
|
|
9
|
+
const { register } = useFormContext();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<input
|
|
13
|
+
type="hidden"
|
|
14
|
+
{...register(field.name)}
|
|
15
|
+
defaultValue={field.default_value}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FormField } from "@/api/types";
|
|
2
|
+
|
|
3
|
+
interface HtmlFieldProps {
|
|
4
|
+
field: FormField;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function HtmlField({ field }: HtmlFieldProps) {
|
|
8
|
+
// HTML fields display static content from default_value
|
|
9
|
+
if (!field.default_value) return null;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
className="lub-form__html-content"
|
|
14
|
+
dangerouslySetInnerHTML={{ __html: field.default_value }}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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 NumberFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function NumberField({ field }: NumberFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
const rules = field.validation_rules;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<FieldWrapper field={field}>
|
|
19
|
+
<input
|
|
20
|
+
id={field.name}
|
|
21
|
+
type="number"
|
|
22
|
+
className={cn("lub-form__input", hasError && "lub-form__input--error")}
|
|
23
|
+
placeholder={field.placeholder}
|
|
24
|
+
min={rules?.min}
|
|
25
|
+
max={rules?.max}
|
|
26
|
+
step="any"
|
|
27
|
+
aria-invalid={hasError}
|
|
28
|
+
aria-describedby={
|
|
29
|
+
hasError
|
|
30
|
+
? `${field.name}-error`
|
|
31
|
+
: field.help_text
|
|
32
|
+
? `${field.name}-help`
|
|
33
|
+
: undefined
|
|
34
|
+
}
|
|
35
|
+
{...register(field.name)}
|
|
36
|
+
/>
|
|
37
|
+
</FieldWrapper>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -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 RadioFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function RadioField({ field }: RadioFieldProps) {
|
|
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__radio-group"
|
|
21
|
+
role="radiogroup"
|
|
22
|
+
aria-labelledby={`${field.name}-label`}
|
|
23
|
+
>
|
|
24
|
+
{options.map((option) => (
|
|
25
|
+
<label key={option.value} className="lub-form__radio-label">
|
|
26
|
+
<input
|
|
27
|
+
type="radio"
|
|
28
|
+
className={cn(
|
|
29
|
+
"lub-form__radio",
|
|
30
|
+
hasError && "lub-form__radio--error",
|
|
31
|
+
)}
|
|
32
|
+
value={option.value}
|
|
33
|
+
{...register(field.name)}
|
|
34
|
+
/>
|
|
35
|
+
<span className="lub-form__radio-text">{option.label}</span>
|
|
36
|
+
</label>
|
|
37
|
+
))}
|
|
38
|
+
{field.options?.allow_other && (
|
|
39
|
+
<label className="lub-form__radio-label">
|
|
40
|
+
<input
|
|
41
|
+
type="radio"
|
|
42
|
+
className={cn(
|
|
43
|
+
"lub-form__radio",
|
|
44
|
+
hasError && "lub-form__radio--error",
|
|
45
|
+
)}
|
|
46
|
+
value="__other__"
|
|
47
|
+
{...register(field.name)}
|
|
48
|
+
/>
|
|
49
|
+
<span className="lub-form__radio-text">
|
|
50
|
+
{field.options.other_label || "Other"}
|
|
51
|
+
</span>
|
|
52
|
+
</label>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</FieldWrapper>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { useFormContext } from "react-hook-form";
|
|
3
|
+
import type { FormField } from "@/api/types";
|
|
4
|
+
import { useLubFormContext } from "@/core/FormProvider";
|
|
5
|
+
|
|
6
|
+
interface RecaptchaFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
grecaptcha?: {
|
|
13
|
+
ready: (callback: () => void) => void;
|
|
14
|
+
execute: (
|
|
15
|
+
siteKey: string,
|
|
16
|
+
options: { action: string },
|
|
17
|
+
) => Promise<string>;
|
|
18
|
+
render: (
|
|
19
|
+
container: HTMLElement,
|
|
20
|
+
options: {
|
|
21
|
+
sitekey: string;
|
|
22
|
+
callback: (token: string) => void;
|
|
23
|
+
"expired-callback"?: () => void;
|
|
24
|
+
"error-callback"?: () => void;
|
|
25
|
+
},
|
|
26
|
+
) => number;
|
|
27
|
+
reset: (widgetId?: number) => void;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function RecaptchaField({ field }: RecaptchaFieldProps) {
|
|
33
|
+
const { setValue, setError, clearErrors } = useFormContext();
|
|
34
|
+
const { form } = useLubFormContext();
|
|
35
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const widgetIdRef = useRef<number | null>(null);
|
|
37
|
+
const scriptLoadedRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
const siteKey = form.settings.recaptcha_site_key;
|
|
40
|
+
|
|
41
|
+
// Callback for v2 checkbox
|
|
42
|
+
const handleV2Callback = useCallback(
|
|
43
|
+
(token: string) => {
|
|
44
|
+
setValue("_recaptcha_token", token);
|
|
45
|
+
clearErrors("_recaptcha_token");
|
|
46
|
+
},
|
|
47
|
+
[setValue, clearErrors],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const handleExpired = useCallback(() => {
|
|
51
|
+
setValue("_recaptcha_token", "");
|
|
52
|
+
setError("_recaptcha_token", {
|
|
53
|
+
type: "manual",
|
|
54
|
+
message: "reCAPTCHA expired, please try again",
|
|
55
|
+
});
|
|
56
|
+
}, [setValue, setError]);
|
|
57
|
+
|
|
58
|
+
// Load reCAPTCHA script
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!siteKey || scriptLoadedRef.current) return;
|
|
61
|
+
|
|
62
|
+
// Check if script already exists
|
|
63
|
+
const existingScript = document.querySelector('script[src*="recaptcha"]');
|
|
64
|
+
if (existingScript) {
|
|
65
|
+
scriptLoadedRef.current = true;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const script = document.createElement("script");
|
|
70
|
+
script.src = `https://www.google.com/recaptcha/api.js?render=explicit`;
|
|
71
|
+
script.async = true;
|
|
72
|
+
script.defer = true;
|
|
73
|
+
script.onload = () => {
|
|
74
|
+
scriptLoadedRef.current = true;
|
|
75
|
+
};
|
|
76
|
+
document.head.appendChild(script);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
// Don't remove script on cleanup as it might be used elsewhere
|
|
80
|
+
};
|
|
81
|
+
}, [siteKey]);
|
|
82
|
+
|
|
83
|
+
// Render v2 widget
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!siteKey || !containerRef.current) return;
|
|
86
|
+
|
|
87
|
+
const renderWidget = () => {
|
|
88
|
+
if (
|
|
89
|
+
window.grecaptcha &&
|
|
90
|
+
containerRef.current &&
|
|
91
|
+
widgetIdRef.current === null
|
|
92
|
+
) {
|
|
93
|
+
window.grecaptcha.ready(() => {
|
|
94
|
+
if (containerRef.current) {
|
|
95
|
+
widgetIdRef.current = window.grecaptcha!.render(
|
|
96
|
+
containerRef.current,
|
|
97
|
+
{
|
|
98
|
+
sitekey: siteKey,
|
|
99
|
+
callback: handleV2Callback,
|
|
100
|
+
"expired-callback": handleExpired,
|
|
101
|
+
"error-callback": handleExpired,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Try rendering, with retry if script isn't loaded yet
|
|
110
|
+
const attemptRender = () => {
|
|
111
|
+
if (window.grecaptcha) {
|
|
112
|
+
renderWidget();
|
|
113
|
+
} else {
|
|
114
|
+
setTimeout(attemptRender, 100);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
attemptRender();
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
if (widgetIdRef.current !== null && window.grecaptcha) {
|
|
122
|
+
window.grecaptcha.reset(widgetIdRef.current);
|
|
123
|
+
widgetIdRef.current = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}, [siteKey, handleV2Callback, handleExpired]);
|
|
127
|
+
|
|
128
|
+
if (!siteKey) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="lub-form__recaptcha">
|
|
134
|
+
<div ref={containerRef} className="lub-form__recaptcha-widget" />
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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 SelectFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SelectField({ field }: SelectFieldProps) {
|
|
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
|
+
<select
|
|
20
|
+
id={field.name}
|
|
21
|
+
className={cn(
|
|
22
|
+
"lub-form__select",
|
|
23
|
+
hasError && "lub-form__select--error",
|
|
24
|
+
)}
|
|
25
|
+
aria-invalid={hasError}
|
|
26
|
+
aria-describedby={
|
|
27
|
+
hasError
|
|
28
|
+
? `${field.name}-error`
|
|
29
|
+
: field.help_text
|
|
30
|
+
? `${field.name}-help`
|
|
31
|
+
: undefined
|
|
32
|
+
}
|
|
33
|
+
{...register(field.name)}
|
|
34
|
+
>
|
|
35
|
+
<option value="">{field.placeholder || "Select an option..."}</option>
|
|
36
|
+
{options.map((option) => (
|
|
37
|
+
<option key={option.value} value={option.value}>
|
|
38
|
+
{option.label}
|
|
39
|
+
</option>
|
|
40
|
+
))}
|
|
41
|
+
{field.options?.allow_other && (
|
|
42
|
+
<option value="__other__">
|
|
43
|
+
{field.options.other_label || "Other"}
|
|
44
|
+
</option>
|
|
45
|
+
)}
|
|
46
|
+
</select>
|
|
47
|
+
</FieldWrapper>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useFormContext, useWatch } from "react-hook-form";
|
|
2
|
+
import type { FormField } from "@/api/types";
|
|
3
|
+
import { FieldWrapper } from "./FieldWrapper";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
import { getStatesForCountry } from "@/utils/countries";
|
|
6
|
+
|
|
7
|
+
interface StateFieldProps {
|
|
8
|
+
field: FormField;
|
|
9
|
+
/** Name of the country field to watch for state filtering */
|
|
10
|
+
countryFieldName?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function StateField({
|
|
14
|
+
field,
|
|
15
|
+
countryFieldName = "country",
|
|
16
|
+
}: StateFieldProps) {
|
|
17
|
+
const { register, formState, control } = useFormContext();
|
|
18
|
+
const error = formState.errors[field.name];
|
|
19
|
+
const hasError = !!error;
|
|
20
|
+
|
|
21
|
+
// Watch country field to filter states
|
|
22
|
+
const countryValue = useWatch({
|
|
23
|
+
control,
|
|
24
|
+
name: countryFieldName,
|
|
25
|
+
defaultValue: "",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const states = getStatesForCountry(countryValue as string);
|
|
29
|
+
const hasStates = states.length > 0;
|
|
30
|
+
|
|
31
|
+
// If no country selected or country has no states, show text input
|
|
32
|
+
if (!hasStates) {
|
|
33
|
+
return (
|
|
34
|
+
<FieldWrapper field={field}>
|
|
35
|
+
<input
|
|
36
|
+
id={field.name}
|
|
37
|
+
type="text"
|
|
38
|
+
className={cn(
|
|
39
|
+
"lub-form__input",
|
|
40
|
+
hasError && "lub-form__input--error",
|
|
41
|
+
)}
|
|
42
|
+
placeholder={field.placeholder || "State/Province"}
|
|
43
|
+
aria-invalid={hasError}
|
|
44
|
+
aria-describedby={
|
|
45
|
+
hasError
|
|
46
|
+
? `${field.name}-error`
|
|
47
|
+
: field.help_text
|
|
48
|
+
? `${field.name}-help`
|
|
49
|
+
: undefined
|
|
50
|
+
}
|
|
51
|
+
{...register(field.name)}
|
|
52
|
+
/>
|
|
53
|
+
</FieldWrapper>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<FieldWrapper field={field}>
|
|
59
|
+
<select
|
|
60
|
+
id={field.name}
|
|
61
|
+
className={cn(
|
|
62
|
+
"lub-form__select",
|
|
63
|
+
hasError && "lub-form__select--error",
|
|
64
|
+
)}
|
|
65
|
+
aria-invalid={hasError}
|
|
66
|
+
aria-describedby={
|
|
67
|
+
hasError
|
|
68
|
+
? `${field.name}-error`
|
|
69
|
+
: field.help_text
|
|
70
|
+
? `${field.name}-help`
|
|
71
|
+
: undefined
|
|
72
|
+
}
|
|
73
|
+
{...register(field.name)}
|
|
74
|
+
>
|
|
75
|
+
<option value="">{field.placeholder || "Select a state..."}</option>
|
|
76
|
+
{states.map((state) => (
|
|
77
|
+
<option key={state.code} value={state.code}>
|
|
78
|
+
{state.name}
|
|
79
|
+
</option>
|
|
80
|
+
))}
|
|
81
|
+
</select>
|
|
82
|
+
</FieldWrapper>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 TextFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TextField({ field }: TextFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
// Determine input type based on field_type
|
|
16
|
+
const inputType = getInputType(field.field_type);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<FieldWrapper field={field}>
|
|
20
|
+
<input
|
|
21
|
+
id={field.name}
|
|
22
|
+
type={inputType}
|
|
23
|
+
className={cn("lub-form__input", hasError && "lub-form__input--error")}
|
|
24
|
+
placeholder={field.placeholder}
|
|
25
|
+
aria-invalid={hasError}
|
|
26
|
+
aria-describedby={
|
|
27
|
+
hasError
|
|
28
|
+
? `${field.name}-error`
|
|
29
|
+
: field.help_text
|
|
30
|
+
? `${field.name}-help`
|
|
31
|
+
: undefined
|
|
32
|
+
}
|
|
33
|
+
{...register(field.name)}
|
|
34
|
+
/>
|
|
35
|
+
</FieldWrapper>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getInputType(fieldType: FormField["field_type"]): string {
|
|
40
|
+
switch (fieldType) {
|
|
41
|
+
case "email":
|
|
42
|
+
return "email";
|
|
43
|
+
case "phone":
|
|
44
|
+
return "tel";
|
|
45
|
+
case "url":
|
|
46
|
+
return "url";
|
|
47
|
+
case "text":
|
|
48
|
+
default:
|
|
49
|
+
return "text";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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 TextareaFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TextareaField({ field }: TextareaFieldProps) {
|
|
11
|
+
const { register, formState } = useFormContext();
|
|
12
|
+
const error = formState.errors[field.name];
|
|
13
|
+
const hasError = !!error;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<FieldWrapper field={field}>
|
|
17
|
+
<textarea
|
|
18
|
+
id={field.name}
|
|
19
|
+
className={cn(
|
|
20
|
+
"lub-form__textarea",
|
|
21
|
+
hasError && "lub-form__textarea--error",
|
|
22
|
+
)}
|
|
23
|
+
placeholder={field.placeholder}
|
|
24
|
+
rows={4}
|
|
25
|
+
aria-invalid={hasError}
|
|
26
|
+
aria-describedby={
|
|
27
|
+
hasError
|
|
28
|
+
? `${field.name}-error`
|
|
29
|
+
: field.help_text
|
|
30
|
+
? `${field.name}-help`
|
|
31
|
+
: undefined
|
|
32
|
+
}
|
|
33
|
+
{...register(field.name)}
|
|
34
|
+
/>
|
|
35
|
+
</FieldWrapper>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -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 TimeFieldProps {
|
|
7
|
+
field: FormField;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TimeField({ field }: TimeFieldProps) {
|
|
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="time"
|
|
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,84 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { FieldType, FormField } from "@/api/types";
|
|
3
|
+
|
|
4
|
+
import { TextField } from "./TextField";
|
|
5
|
+
import { TextareaField } from "./TextareaField";
|
|
6
|
+
import { NumberField } from "./NumberField";
|
|
7
|
+
import { SelectField } from "./SelectField";
|
|
8
|
+
import { RadioField } from "./RadioField";
|
|
9
|
+
import { CheckboxField } from "./CheckboxField";
|
|
10
|
+
import { CheckboxGroupField } from "./CheckboxGroupField";
|
|
11
|
+
import { DateField } from "./DateField";
|
|
12
|
+
import { TimeField } from "./TimeField";
|
|
13
|
+
import { DateTimeField } from "./DateTimeField";
|
|
14
|
+
import { FileField } from "./FileField";
|
|
15
|
+
import { HiddenField } from "./HiddenField";
|
|
16
|
+
import { CountryField } from "./CountryField";
|
|
17
|
+
import { StateField } from "./StateField";
|
|
18
|
+
import { HtmlField } from "./HtmlField";
|
|
19
|
+
import { DividerField } from "./DividerField";
|
|
20
|
+
import { RecaptchaField } from "./RecaptchaField";
|
|
21
|
+
|
|
22
|
+
// Field component type
|
|
23
|
+
export type FieldComponent = ComponentType<{ field: FormField }>;
|
|
24
|
+
|
|
25
|
+
// Field component registry
|
|
26
|
+
const fieldRegistry: Record<FieldType, FieldComponent> = {
|
|
27
|
+
text: TextField,
|
|
28
|
+
email: TextField,
|
|
29
|
+
phone: TextField,
|
|
30
|
+
url: TextField,
|
|
31
|
+
textarea: TextareaField,
|
|
32
|
+
number: NumberField,
|
|
33
|
+
select: SelectField,
|
|
34
|
+
radio: RadioField,
|
|
35
|
+
checkbox: CheckboxField,
|
|
36
|
+
checkbox_group: CheckboxGroupField,
|
|
37
|
+
date: DateField,
|
|
38
|
+
time: TimeField,
|
|
39
|
+
datetime: DateTimeField,
|
|
40
|
+
file: FileField,
|
|
41
|
+
hidden: HiddenField,
|
|
42
|
+
country: CountryField,
|
|
43
|
+
state: StateField,
|
|
44
|
+
html: HtmlField,
|
|
45
|
+
divider: DividerField,
|
|
46
|
+
recaptcha: RecaptchaField,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the component for a field type
|
|
51
|
+
*/
|
|
52
|
+
export function getFieldComponent(type: FieldType): FieldComponent | null {
|
|
53
|
+
return fieldRegistry[type] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register a custom field component
|
|
58
|
+
*/
|
|
59
|
+
export function registerFieldComponent(
|
|
60
|
+
type: string,
|
|
61
|
+
component: FieldComponent,
|
|
62
|
+
): void {
|
|
63
|
+
(fieldRegistry as Record<string, FieldComponent>)[type] = component;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Export all field components
|
|
67
|
+
export { FieldWrapper } from "./FieldWrapper";
|
|
68
|
+
export { TextField } from "./TextField";
|
|
69
|
+
export { TextareaField } from "./TextareaField";
|
|
70
|
+
export { NumberField } from "./NumberField";
|
|
71
|
+
export { SelectField } from "./SelectField";
|
|
72
|
+
export { RadioField } from "./RadioField";
|
|
73
|
+
export { CheckboxField } from "./CheckboxField";
|
|
74
|
+
export { CheckboxGroupField } from "./CheckboxGroupField";
|
|
75
|
+
export { DateField } from "./DateField";
|
|
76
|
+
export { TimeField } from "./TimeField";
|
|
77
|
+
export { DateTimeField } from "./DateTimeField";
|
|
78
|
+
export { FileField } from "./FileField";
|
|
79
|
+
export { HiddenField } from "./HiddenField";
|
|
80
|
+
export { CountryField } from "./CountryField";
|
|
81
|
+
export { StateField } from "./StateField";
|
|
82
|
+
export { HtmlField } from "./HtmlField";
|
|
83
|
+
export { DividerField } from "./DividerField";
|
|
84
|
+
export { RecaptchaField } from "./RecaptchaField";
|