@aspect-ops/exon-ui 0.2.0 → 0.2.2
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 +200 -0
- package/dist/components/Accordion/Accordion.svelte +2 -2
- package/dist/components/Accordion/AccordionItem.svelte +2 -2
- package/dist/components/AspectRatio/AspectRatio.svelte +1 -0
- package/dist/components/Card/FlipCard.svelte +155 -0
- package/dist/components/Card/FlipCard.svelte.d.ts +13 -0
- package/dist/components/Card/index.d.ts +1 -0
- package/dist/components/Card/index.js +1 -0
- package/dist/components/Chatbot/ChatMessage.svelte +143 -0
- package/dist/components/Chatbot/ChatMessage.svelte.d.ts +8 -0
- package/dist/components/Chatbot/Chatbot.svelte +640 -0
- package/dist/components/Chatbot/Chatbot.svelte.d.ts +22 -0
- package/dist/components/Chatbot/index.d.ts +3 -0
- package/dist/components/Chatbot/index.js +2 -0
- package/dist/components/Chatbot/types.d.ts +48 -0
- package/dist/components/Chatbot/types.js +2 -0
- package/dist/components/ContactForm/ContactForm.svelte +564 -0
- package/dist/components/ContactForm/ContactForm.svelte.d.ts +44 -0
- package/dist/components/ContactForm/index.d.ts +1 -0
- package/dist/components/ContactForm/index.js +1 -0
- package/dist/components/Container/Container.svelte +1 -0
- package/dist/components/DataTable/DataTable.svelte +460 -0
- package/dist/components/DataTable/DataTable.svelte.d.ts +49 -0
- package/dist/components/DataTable/index.d.ts +2 -0
- package/dist/components/DataTable/index.js +1 -0
- package/dist/components/DoughnutChart/DoughnutChart.svelte +390 -0
- package/dist/components/DoughnutChart/DoughnutChart.svelte.d.ts +25 -0
- package/dist/components/DoughnutChart/index.d.ts +1 -0
- package/dist/components/DoughnutChart/index.js +1 -0
- package/dist/components/FAB/FAB.svelte +5 -1
- package/dist/components/FAB/FABGroup.svelte +10 -2
- package/dist/components/FileUpload/FileUpload.svelte +12 -12
- package/dist/components/Icon/Icon.svelte +15 -18
- package/dist/components/Icon/Icon.svelte.d.ts +2 -1
- package/dist/components/Menu/MenuContent.svelte +1 -0
- package/dist/components/Menu/MenuSubContent.svelte +1 -0
- package/dist/components/Mermaid/Mermaid.svelte +320 -0
- package/dist/components/Mermaid/Mermaid.svelte.d.ts +38 -0
- package/dist/components/Mermaid/index.d.ts +1 -0
- package/dist/components/Mermaid/index.js +1 -0
- package/dist/components/Mermaid/mermaid.d.ts +21 -0
- package/dist/components/PageHeader/PageHeader.svelte +140 -0
- package/dist/components/PageHeader/PageHeader.svelte.d.ts +30 -0
- package/dist/components/PageHeader/index.d.ts +1 -0
- package/dist/components/PageHeader/index.js +1 -0
- package/dist/components/Popover/PopoverTrigger.svelte +1 -3
- package/dist/components/StatCircle/StatCircle.svelte +172 -0
- package/dist/components/StatCircle/StatCircle.svelte.d.ts +19 -0
- package/dist/components/StatCircle/index.d.ts +1 -0
- package/dist/components/StatCircle/index.js +1 -0
- package/dist/components/StatsCard/StatsCard.svelte +301 -0
- package/dist/components/StatsCard/StatsCard.svelte.d.ts +32 -0
- package/dist/components/StatsCard/index.d.ts +2 -0
- package/dist/components/StatsCard/index.js +1 -0
- package/dist/components/StatusBadge/StatusBadge.svelte +221 -0
- package/dist/components/StatusBadge/StatusBadge.svelte.d.ts +22 -0
- package/dist/components/StatusBadge/index.d.ts +2 -0
- package/dist/components/StatusBadge/index.js +1 -0
- package/dist/components/StatusBanner/StatusBanner.svelte +325 -0
- package/dist/components/StatusBanner/StatusBanner.svelte.d.ts +13 -0
- package/dist/components/StatusBanner/index.d.ts +1 -0
- package/dist/components/StatusBanner/index.js +1 -0
- package/dist/components/ViewCounter/ViewCounter.svelte +157 -0
- package/dist/components/ViewCounter/ViewCounter.svelte.d.ts +17 -0
- package/dist/components/ViewCounter/index.d.ts +1 -0
- package/dist/components/ViewCounter/index.js +1 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +16 -1
- package/dist/styles/tokens.css +2 -1
- package/dist/types/data-display.d.ts +72 -0
- package/dist/types/feedback.d.ts +10 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/input.d.ts +20 -0
- package/package.json +4 -2
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
|
|
4
|
+
// Field configuration
|
|
5
|
+
interface FieldConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
label: string;
|
|
8
|
+
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
options?: { value: string; label: string }[];
|
|
12
|
+
rows?: number;
|
|
13
|
+
validation?: (value: string) => string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FormData {
|
|
17
|
+
[key: string]: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FeedbackState {
|
|
21
|
+
type: 'success' | 'error' | 'warning' | 'info' | null;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Form fields configuration */
|
|
27
|
+
fields?: FieldConfig[];
|
|
28
|
+
/** Initial form values */
|
|
29
|
+
initialValues?: FormData;
|
|
30
|
+
/** Submit button text */
|
|
31
|
+
submitText?: string;
|
|
32
|
+
/** Whether to show success message after submit */
|
|
33
|
+
showSuccessMessage?: boolean;
|
|
34
|
+
/** Success message text */
|
|
35
|
+
successMessage?: string;
|
|
36
|
+
/** Privacy notice text */
|
|
37
|
+
privacyNotice?: string;
|
|
38
|
+
/** Whether to extract UTM params from URL */
|
|
39
|
+
extractUtmParams?: boolean;
|
|
40
|
+
/** Custom class */
|
|
41
|
+
class?: string;
|
|
42
|
+
/** Called on form submit. Should return { success: boolean, error?: string } */
|
|
43
|
+
onSubmit?: (data: FormData) => Promise<{ success: boolean; error?: string }>;
|
|
44
|
+
/** Called when validation state changes */
|
|
45
|
+
onValidate?: (isValid: boolean, errors: Record<string, string>) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Default fields
|
|
49
|
+
const defaultFields: FieldConfig[] = [
|
|
50
|
+
{
|
|
51
|
+
name: 'firstName',
|
|
52
|
+
label: 'First Name',
|
|
53
|
+
type: 'text',
|
|
54
|
+
placeholder: 'John',
|
|
55
|
+
required: true
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'lastName',
|
|
59
|
+
label: 'Last Name',
|
|
60
|
+
type: 'text',
|
|
61
|
+
placeholder: 'Doe',
|
|
62
|
+
required: true
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'email',
|
|
66
|
+
label: 'Email',
|
|
67
|
+
type: 'email',
|
|
68
|
+
placeholder: 'john@company.com',
|
|
69
|
+
required: true,
|
|
70
|
+
validation: (value) => {
|
|
71
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
72
|
+
return emailRegex.test(value) ? null : 'Please enter a valid email address';
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'phone',
|
|
77
|
+
label: 'Phone',
|
|
78
|
+
type: 'tel',
|
|
79
|
+
placeholder: '+1 (555) 123-4567'
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'company',
|
|
83
|
+
label: 'Company',
|
|
84
|
+
type: 'text',
|
|
85
|
+
placeholder: 'Acme Inc.'
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'message',
|
|
89
|
+
label: 'Message',
|
|
90
|
+
type: 'textarea',
|
|
91
|
+
placeholder: 'How can we help you?',
|
|
92
|
+
rows: 4,
|
|
93
|
+
required: true
|
|
94
|
+
}
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
let {
|
|
98
|
+
fields = defaultFields,
|
|
99
|
+
initialValues = {},
|
|
100
|
+
submitText = 'Submit',
|
|
101
|
+
showSuccessMessage = true,
|
|
102
|
+
successMessage = "Thank you! We'll be in touch soon.",
|
|
103
|
+
privacyNotice = 'By submitting this form, you agree to our privacy policy.',
|
|
104
|
+
extractUtmParams = true,
|
|
105
|
+
class: className = '',
|
|
106
|
+
onSubmit,
|
|
107
|
+
onValidate
|
|
108
|
+
}: Props = $props();
|
|
109
|
+
|
|
110
|
+
// State - intentionally capture initial values
|
|
111
|
+
// eslint-disable-next-line svelte/valid-compile
|
|
112
|
+
let formData = $state<FormData>({ ...initialValues });
|
|
113
|
+
let errors = $state<Record<string, string>>({});
|
|
114
|
+
let touched = $state<Record<string, boolean>>({});
|
|
115
|
+
let isSubmitting = $state(false);
|
|
116
|
+
let feedback = $state<FeedbackState>({ type: null, message: '' });
|
|
117
|
+
let utmParams = $state<Record<string, string>>({});
|
|
118
|
+
|
|
119
|
+
// Initialize form data with field defaults
|
|
120
|
+
$effect(() => {
|
|
121
|
+
fields.forEach((field) => {
|
|
122
|
+
if (formData[field.name] === undefined) {
|
|
123
|
+
formData[field.name] = initialValues[field.name] || '';
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Extract UTM params on mount
|
|
129
|
+
onMount(() => {
|
|
130
|
+
if (extractUtmParams && typeof window !== 'undefined') {
|
|
131
|
+
const params = new URLSearchParams(window.location.search);
|
|
132
|
+
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
133
|
+
utmKeys.forEach((key) => {
|
|
134
|
+
const value = params.get(key);
|
|
135
|
+
if (value) {
|
|
136
|
+
utmParams[key] = value;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Validate single field
|
|
143
|
+
function validateField(field: FieldConfig, value: string): string | null {
|
|
144
|
+
if (field.required && !value.trim()) {
|
|
145
|
+
return `${field.label} is required`;
|
|
146
|
+
}
|
|
147
|
+
if (field.validation) {
|
|
148
|
+
return field.validation(value);
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate all fields
|
|
154
|
+
function validateAll(): boolean {
|
|
155
|
+
const newErrors: Record<string, string> = {};
|
|
156
|
+
let isValid = true;
|
|
157
|
+
|
|
158
|
+
fields.forEach((field) => {
|
|
159
|
+
const error = validateField(field, formData[field.name] || '');
|
|
160
|
+
if (error) {
|
|
161
|
+
newErrors[field.name] = error;
|
|
162
|
+
isValid = false;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
errors = newErrors;
|
|
167
|
+
onValidate?.(isValid, newErrors);
|
|
168
|
+
return isValid;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Handle field blur
|
|
172
|
+
function handleBlur(field: FieldConfig) {
|
|
173
|
+
touched[field.name] = true;
|
|
174
|
+
const error = validateField(field, formData[field.name] || '');
|
|
175
|
+
if (error) {
|
|
176
|
+
errors[field.name] = error;
|
|
177
|
+
} else {
|
|
178
|
+
delete errors[field.name];
|
|
179
|
+
errors = { ...errors };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Handle input change
|
|
184
|
+
function handleInput(field: FieldConfig, value: string) {
|
|
185
|
+
formData[field.name] = value;
|
|
186
|
+
|
|
187
|
+
// Clear error on input if field was touched
|
|
188
|
+
if (touched[field.name]) {
|
|
189
|
+
const error = validateField(field, value);
|
|
190
|
+
if (error) {
|
|
191
|
+
errors[field.name] = error;
|
|
192
|
+
} else {
|
|
193
|
+
delete errors[field.name];
|
|
194
|
+
errors = { ...errors };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Handle form submit
|
|
200
|
+
async function handleSubmit(event: SubmitEvent) {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
|
|
203
|
+
// Mark all fields as touched
|
|
204
|
+
fields.forEach((field) => {
|
|
205
|
+
touched[field.name] = true;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!validateAll()) {
|
|
209
|
+
feedback = {
|
|
210
|
+
type: 'error',
|
|
211
|
+
message: 'Please fix the errors above.'
|
|
212
|
+
};
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!onSubmit) {
|
|
217
|
+
feedback = {
|
|
218
|
+
type: 'warning',
|
|
219
|
+
message: 'No submit handler configured.'
|
|
220
|
+
};
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
isSubmitting = true;
|
|
225
|
+
feedback = { type: null, message: '' };
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
// Combine form data with UTM params
|
|
229
|
+
const submitData = {
|
|
230
|
+
...formData,
|
|
231
|
+
...utmParams
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const result = await onSubmit(submitData);
|
|
235
|
+
|
|
236
|
+
if (result.success) {
|
|
237
|
+
if (showSuccessMessage) {
|
|
238
|
+
feedback = {
|
|
239
|
+
type: 'success',
|
|
240
|
+
message: successMessage
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// Reset form
|
|
244
|
+
formData = { ...initialValues };
|
|
245
|
+
touched = {};
|
|
246
|
+
errors = {};
|
|
247
|
+
} else {
|
|
248
|
+
feedback = {
|
|
249
|
+
type: 'error',
|
|
250
|
+
message: result.error || 'Something went wrong. Please try again.'
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
feedback = {
|
|
255
|
+
type: 'error',
|
|
256
|
+
message: 'An unexpected error occurred. Please try again.'
|
|
257
|
+
};
|
|
258
|
+
} finally {
|
|
259
|
+
isSubmitting = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check if form has errors
|
|
264
|
+
const hasErrors = $derived(Object.keys(errors).length > 0);
|
|
265
|
+
</script>
|
|
266
|
+
|
|
267
|
+
<form class="contact-form {className}" onsubmit={handleSubmit} novalidate>
|
|
268
|
+
{#if feedback.type}
|
|
269
|
+
<div class="contact-form__feedback contact-form__feedback--{feedback.type}" role="alert">
|
|
270
|
+
{#if feedback.type === 'success'}
|
|
271
|
+
<svg
|
|
272
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
273
|
+
width="20"
|
|
274
|
+
height="20"
|
|
275
|
+
viewBox="0 0 24 24"
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke="currentColor"
|
|
278
|
+
stroke-width="2"
|
|
279
|
+
>
|
|
280
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
281
|
+
<polyline points="22 4 12 14.01 9 11.01" />
|
|
282
|
+
</svg>
|
|
283
|
+
{:else if feedback.type === 'error'}
|
|
284
|
+
<svg
|
|
285
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
286
|
+
width="20"
|
|
287
|
+
height="20"
|
|
288
|
+
viewBox="0 0 24 24"
|
|
289
|
+
fill="none"
|
|
290
|
+
stroke="currentColor"
|
|
291
|
+
stroke-width="2"
|
|
292
|
+
>
|
|
293
|
+
<circle cx="12" cy="12" r="10" />
|
|
294
|
+
<line x1="15" y1="9" x2="9" y2="15" />
|
|
295
|
+
<line x1="9" y1="9" x2="15" y2="15" />
|
|
296
|
+
</svg>
|
|
297
|
+
{/if}
|
|
298
|
+
<span>{feedback.message}</span>
|
|
299
|
+
</div>
|
|
300
|
+
{/if}
|
|
301
|
+
|
|
302
|
+
<div class="contact-form__fields">
|
|
303
|
+
{#each fields as field}
|
|
304
|
+
<div
|
|
305
|
+
class="contact-form__field"
|
|
306
|
+
class:contact-form__field--error={touched[field.name] && errors[field.name]}
|
|
307
|
+
class:contact-form__field--full={field.type === 'textarea'}
|
|
308
|
+
>
|
|
309
|
+
<label class="contact-form__label" for={field.name}>
|
|
310
|
+
{field.label}
|
|
311
|
+
{#if field.required}
|
|
312
|
+
<span class="contact-form__required">*</span>
|
|
313
|
+
{/if}
|
|
314
|
+
</label>
|
|
315
|
+
|
|
316
|
+
{#if field.type === 'textarea'}
|
|
317
|
+
<textarea
|
|
318
|
+
id={field.name}
|
|
319
|
+
name={field.name}
|
|
320
|
+
class="contact-form__input contact-form__textarea"
|
|
321
|
+
placeholder={field.placeholder}
|
|
322
|
+
rows={field.rows || 4}
|
|
323
|
+
value={formData[field.name] || ''}
|
|
324
|
+
oninput={(e) => handleInput(field, e.currentTarget.value)}
|
|
325
|
+
onblur={() => handleBlur(field)}
|
|
326
|
+
aria-invalid={touched[field.name] && !!errors[field.name]}
|
|
327
|
+
aria-describedby={errors[field.name] ? `${field.name}-error` : undefined}
|
|
328
|
+
></textarea>
|
|
329
|
+
{:else if field.type === 'select' && field.options}
|
|
330
|
+
<select
|
|
331
|
+
id={field.name}
|
|
332
|
+
name={field.name}
|
|
333
|
+
class="contact-form__input contact-form__select"
|
|
334
|
+
value={formData[field.name] || ''}
|
|
335
|
+
onchange={(e) => handleInput(field, e.currentTarget.value)}
|
|
336
|
+
onblur={() => handleBlur(field)}
|
|
337
|
+
aria-invalid={touched[field.name] && !!errors[field.name]}
|
|
338
|
+
>
|
|
339
|
+
<option value="">{field.placeholder || 'Select an option'}</option>
|
|
340
|
+
{#each field.options as option}
|
|
341
|
+
<option value={option.value}>{option.label}</option>
|
|
342
|
+
{/each}
|
|
343
|
+
</select>
|
|
344
|
+
{:else}
|
|
345
|
+
<input
|
|
346
|
+
id={field.name}
|
|
347
|
+
name={field.name}
|
|
348
|
+
type={field.type}
|
|
349
|
+
class="contact-form__input"
|
|
350
|
+
placeholder={field.placeholder}
|
|
351
|
+
value={formData[field.name] || ''}
|
|
352
|
+
oninput={(e) => handleInput(field, e.currentTarget.value)}
|
|
353
|
+
onblur={() => handleBlur(field)}
|
|
354
|
+
aria-invalid={touched[field.name] && !!errors[field.name]}
|
|
355
|
+
aria-describedby={errors[field.name] ? `${field.name}-error` : undefined}
|
|
356
|
+
/>
|
|
357
|
+
{/if}
|
|
358
|
+
|
|
359
|
+
{#if touched[field.name] && errors[field.name]}
|
|
360
|
+
<span id="{field.name}-error" class="contact-form__error" role="alert">
|
|
361
|
+
{errors[field.name]}
|
|
362
|
+
</span>
|
|
363
|
+
{/if}
|
|
364
|
+
</div>
|
|
365
|
+
{/each}
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
{#if privacyNotice}
|
|
369
|
+
<p class="contact-form__privacy">{privacyNotice}</p>
|
|
370
|
+
{/if}
|
|
371
|
+
|
|
372
|
+
<button type="submit" class="contact-form__submit" disabled={isSubmitting}>
|
|
373
|
+
{#if isSubmitting}
|
|
374
|
+
<span class="contact-form__spinner"></span>
|
|
375
|
+
Submitting...
|
|
376
|
+
{:else}
|
|
377
|
+
{submitText}
|
|
378
|
+
{/if}
|
|
379
|
+
</button>
|
|
380
|
+
</form>
|
|
381
|
+
|
|
382
|
+
<style>
|
|
383
|
+
.contact-form {
|
|
384
|
+
display: flex;
|
|
385
|
+
flex-direction: column;
|
|
386
|
+
gap: var(--space-lg, 1.5rem);
|
|
387
|
+
font-family: inherit;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* Feedback */
|
|
391
|
+
.contact-form__feedback {
|
|
392
|
+
display: flex;
|
|
393
|
+
align-items: center;
|
|
394
|
+
gap: var(--space-sm, 0.5rem);
|
|
395
|
+
padding: var(--space-md, 1rem);
|
|
396
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
397
|
+
font-size: var(--text-sm, 0.875rem);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.contact-form__feedback--success {
|
|
401
|
+
background: var(--color-success-bg, #dcfce7);
|
|
402
|
+
color: var(--color-success, #16a34a);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.contact-form__feedback--error {
|
|
406
|
+
background: var(--color-error-bg, #fee2e2);
|
|
407
|
+
color: var(--color-error, #dc2626);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.contact-form__feedback--warning {
|
|
411
|
+
background: var(--color-warning-bg, #fef3c7);
|
|
412
|
+
color: var(--color-warning, #d97706);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.contact-form__feedback--info {
|
|
416
|
+
background: var(--color-primary-bg, #dbeafe);
|
|
417
|
+
color: var(--color-primary, #2563eb);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Fields Grid */
|
|
421
|
+
.contact-form__fields {
|
|
422
|
+
display: grid;
|
|
423
|
+
grid-template-columns: repeat(2, 1fr);
|
|
424
|
+
gap: var(--space-md, 1rem);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
@media (max-width: 640px) {
|
|
428
|
+
.contact-form__fields {
|
|
429
|
+
grid-template-columns: 1fr;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* Field */
|
|
434
|
+
.contact-form__field {
|
|
435
|
+
display: flex;
|
|
436
|
+
flex-direction: column;
|
|
437
|
+
gap: var(--space-xs, 0.25rem);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.contact-form__field--full {
|
|
441
|
+
grid-column: 1 / -1;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* Label */
|
|
445
|
+
.contact-form__label {
|
|
446
|
+
font-size: var(--text-sm, 0.875rem);
|
|
447
|
+
font-weight: var(--font-medium, 500);
|
|
448
|
+
color: var(--color-text, #1f2937);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.contact-form__required {
|
|
452
|
+
color: var(--color-error, #dc2626);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* Input */
|
|
456
|
+
.contact-form__input {
|
|
457
|
+
min-height: var(--touch-target-min, 44px);
|
|
458
|
+
padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
|
|
459
|
+
border: 1px solid var(--color-border, #d1d5db);
|
|
460
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
461
|
+
background: var(--color-bg, #ffffff);
|
|
462
|
+
color: var(--color-text, #1f2937);
|
|
463
|
+
font-family: inherit;
|
|
464
|
+
font-size: var(--text-sm, 0.875rem);
|
|
465
|
+
transition:
|
|
466
|
+
border-color var(--transition-fast, 150ms ease),
|
|
467
|
+
box-shadow var(--transition-fast, 150ms ease);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.contact-form__input::placeholder {
|
|
471
|
+
color: var(--color-text-muted, #9ca3af);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.contact-form__input:focus {
|
|
475
|
+
outline: none;
|
|
476
|
+
border-color: var(--color-primary, #3b82f6);
|
|
477
|
+
box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.contact-form__field--error .contact-form__input {
|
|
481
|
+
border-color: var(--color-error, #dc2626);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.contact-form__field--error .contact-form__input:focus {
|
|
485
|
+
box-shadow: 0 0 0 3px var(--color-error-alpha, rgba(220, 38, 38, 0.1));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* Textarea */
|
|
489
|
+
.contact-form__textarea {
|
|
490
|
+
resize: vertical;
|
|
491
|
+
min-height: 100px;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* Select */
|
|
495
|
+
.contact-form__select {
|
|
496
|
+
appearance: none;
|
|
497
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
|
498
|
+
background-repeat: no-repeat;
|
|
499
|
+
background-position: right 0.75rem center;
|
|
500
|
+
padding-right: 2.5rem;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* Error message */
|
|
504
|
+
.contact-form__error {
|
|
505
|
+
font-size: var(--text-xs, 0.75rem);
|
|
506
|
+
color: var(--color-error, #dc2626);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* Privacy */
|
|
510
|
+
.contact-form__privacy {
|
|
511
|
+
font-size: var(--text-xs, 0.75rem);
|
|
512
|
+
color: var(--color-text-muted, #6b7280);
|
|
513
|
+
margin: 0;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* Submit */
|
|
517
|
+
.contact-form__submit {
|
|
518
|
+
display: flex;
|
|
519
|
+
align-items: center;
|
|
520
|
+
justify-content: center;
|
|
521
|
+
gap: var(--space-sm, 0.5rem);
|
|
522
|
+
min-height: var(--touch-target-min, 44px);
|
|
523
|
+
padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem);
|
|
524
|
+
border: none;
|
|
525
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
526
|
+
background: var(--color-primary, #3b82f6);
|
|
527
|
+
color: var(--color-text-inverse, #ffffff);
|
|
528
|
+
font-family: inherit;
|
|
529
|
+
font-size: var(--text-sm, 0.875rem);
|
|
530
|
+
font-weight: var(--font-medium, 500);
|
|
531
|
+
cursor: pointer;
|
|
532
|
+
transition: background var(--transition-fast, 150ms ease);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.contact-form__submit:hover:not(:disabled) {
|
|
536
|
+
background: var(--color-primary-hover, #2563eb);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.contact-form__submit:focus-visible {
|
|
540
|
+
outline: 2px solid var(--color-primary, #3b82f6);
|
|
541
|
+
outline-offset: 2px;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.contact-form__submit:disabled {
|
|
545
|
+
opacity: 0.7;
|
|
546
|
+
cursor: not-allowed;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* Spinner */
|
|
550
|
+
.contact-form__spinner {
|
|
551
|
+
width: 1rem;
|
|
552
|
+
height: 1rem;
|
|
553
|
+
border: 2px solid transparent;
|
|
554
|
+
border-top-color: currentColor;
|
|
555
|
+
border-radius: var(--radius-full, 9999px);
|
|
556
|
+
animation: spin 0.75s linear infinite;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
@keyframes spin {
|
|
560
|
+
to {
|
|
561
|
+
transform: rotate(360deg);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
interface FieldConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
label: string;
|
|
4
|
+
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
options?: {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}[];
|
|
11
|
+
rows?: number;
|
|
12
|
+
validation?: (value: string) => string | null;
|
|
13
|
+
}
|
|
14
|
+
interface FormData {
|
|
15
|
+
[key: string]: string;
|
|
16
|
+
}
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Form fields configuration */
|
|
19
|
+
fields?: FieldConfig[];
|
|
20
|
+
/** Initial form values */
|
|
21
|
+
initialValues?: FormData;
|
|
22
|
+
/** Submit button text */
|
|
23
|
+
submitText?: string;
|
|
24
|
+
/** Whether to show success message after submit */
|
|
25
|
+
showSuccessMessage?: boolean;
|
|
26
|
+
/** Success message text */
|
|
27
|
+
successMessage?: string;
|
|
28
|
+
/** Privacy notice text */
|
|
29
|
+
privacyNotice?: string;
|
|
30
|
+
/** Whether to extract UTM params from URL */
|
|
31
|
+
extractUtmParams?: boolean;
|
|
32
|
+
/** Custom class */
|
|
33
|
+
class?: string;
|
|
34
|
+
/** Called on form submit. Should return { success: boolean, error?: string } */
|
|
35
|
+
onSubmit?: (data: FormData) => Promise<{
|
|
36
|
+
success: boolean;
|
|
37
|
+
error?: string;
|
|
38
|
+
}>;
|
|
39
|
+
/** Called when validation state changes */
|
|
40
|
+
onValidate?: (isValid: boolean, errors: Record<string, string>) => void;
|
|
41
|
+
}
|
|
42
|
+
declare const ContactForm: import("svelte").Component<Props, {}, "">;
|
|
43
|
+
type ContactForm = ReturnType<typeof ContactForm>;
|
|
44
|
+
export default ContactForm;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ContactForm } from './ContactForm.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ContactForm } from './ContactForm.svelte';
|