@htlkg/components 0.0.1
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/dist/composables/index.js +388 -0
- package/dist/composables/index.js.map +1 -0
- package/package.json +41 -0
- package/src/composables/index.ts +6 -0
- package/src/composables/useForm.test.ts +229 -0
- package/src/composables/useForm.ts +130 -0
- package/src/composables/useFormValidation.test.ts +189 -0
- package/src/composables/useFormValidation.ts +83 -0
- package/src/composables/useModal.property.test.ts +164 -0
- package/src/composables/useModal.ts +43 -0
- package/src/composables/useNotifications.test.ts +166 -0
- package/src/composables/useNotifications.ts +81 -0
- package/src/composables/useTable.property.test.ts +198 -0
- package/src/composables/useTable.ts +134 -0
- package/src/composables/useTabs.property.test.ts +247 -0
- package/src/composables/useTabs.ts +101 -0
- package/src/data/Chart.demo.vue +340 -0
- package/src/data/Chart.md +525 -0
- package/src/data/Chart.vue +133 -0
- package/src/data/DataList.md +80 -0
- package/src/data/DataList.test.ts +69 -0
- package/src/data/DataList.vue +46 -0
- package/src/data/SearchableSelect.md +107 -0
- package/src/data/SearchableSelect.vue +124 -0
- package/src/data/Table.demo.vue +296 -0
- package/src/data/Table.md +588 -0
- package/src/data/Table.property.test.ts +548 -0
- package/src/data/Table.test.ts +562 -0
- package/src/data/Table.unit.test.ts +544 -0
- package/src/data/Table.vue +321 -0
- package/src/data/index.ts +5 -0
- package/src/domain/BrandCard.md +81 -0
- package/src/domain/BrandCard.vue +63 -0
- package/src/domain/BrandSelector.md +84 -0
- package/src/domain/BrandSelector.vue +65 -0
- package/src/domain/ProductBadge.md +60 -0
- package/src/domain/ProductBadge.vue +47 -0
- package/src/domain/UserAvatar.md +84 -0
- package/src/domain/UserAvatar.vue +60 -0
- package/src/domain/domain-components.property.test.ts +449 -0
- package/src/domain/index.ts +4 -0
- package/src/forms/DateRange.demo.vue +273 -0
- package/src/forms/DateRange.md +337 -0
- package/src/forms/DateRange.vue +110 -0
- package/src/forms/JsonSchemaForm.demo.vue +549 -0
- package/src/forms/JsonSchemaForm.md +112 -0
- package/src/forms/JsonSchemaForm.property.test.ts +817 -0
- package/src/forms/JsonSchemaForm.test.ts +601 -0
- package/src/forms/JsonSchemaForm.unit.test.ts +801 -0
- package/src/forms/JsonSchemaForm.vue +615 -0
- package/src/forms/index.ts +3 -0
- package/src/index.ts +17 -0
- package/src/navigation/Breadcrumbs.demo.vue +142 -0
- package/src/navigation/Breadcrumbs.md +102 -0
- package/src/navigation/Breadcrumbs.test.ts +69 -0
- package/src/navigation/Breadcrumbs.vue +58 -0
- package/src/navigation/Stepper.demo.vue +337 -0
- package/src/navigation/Stepper.md +174 -0
- package/src/navigation/Stepper.vue +146 -0
- package/src/navigation/Tabs.demo.vue +293 -0
- package/src/navigation/Tabs.md +163 -0
- package/src/navigation/Tabs.test.ts +176 -0
- package/src/navigation/Tabs.vue +104 -0
- package/src/navigation/index.ts +5 -0
- package/src/overlays/Alert.demo.vue +377 -0
- package/src/overlays/Alert.md +248 -0
- package/src/overlays/Alert.test.ts +166 -0
- package/src/overlays/Alert.vue +70 -0
- package/src/overlays/Drawer.md +140 -0
- package/src/overlays/Drawer.test.ts +92 -0
- package/src/overlays/Drawer.vue +76 -0
- package/src/overlays/Modal.demo.vue +149 -0
- package/src/overlays/Modal.md +385 -0
- package/src/overlays/Modal.test.ts +128 -0
- package/src/overlays/Modal.vue +86 -0
- package/src/overlays/Notification.md +150 -0
- package/src/overlays/Notification.test.ts +96 -0
- package/src/overlays/Notification.vue +58 -0
- package/src/overlays/index.ts +4 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
import Ajv from 'ajv';
|
|
4
|
+
import addFormats from 'ajv-formats';
|
|
5
|
+
import {
|
|
6
|
+
uiInput,
|
|
7
|
+
uiSelect,
|
|
8
|
+
uiToggle,
|
|
9
|
+
uiRangeSlider,
|
|
10
|
+
uiTextArea,
|
|
11
|
+
uiButton
|
|
12
|
+
} from '@hotelinking/ui';
|
|
13
|
+
|
|
14
|
+
// Initialize AJV for JSON Schema validation with format support
|
|
15
|
+
const ajv = new Ajv({ allErrors: true });
|
|
16
|
+
addFormats(ajv); // Add support for format keywords like "email", "uri", "date", etc.
|
|
17
|
+
|
|
18
|
+
interface JsonSchema {
|
|
19
|
+
type: string;
|
|
20
|
+
properties?: Record<string, any>;
|
|
21
|
+
required?: string[];
|
|
22
|
+
title?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
schema: JsonSchema;
|
|
28
|
+
modelValue?: Record<string, any>;
|
|
29
|
+
uiSchema?: Record<string, any>;
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
34
|
+
modelValue: () => ({}),
|
|
35
|
+
uiSchema: () => ({}),
|
|
36
|
+
loading: false
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
'update:modelValue': [value: Record<string, any>];
|
|
41
|
+
'validation-error': [errors: Array<{ field: string; message: string }>];
|
|
42
|
+
'submit': [value: Record<string, any>];
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
// Internal form data
|
|
46
|
+
const formData = computed({
|
|
47
|
+
get: () => props.modelValue,
|
|
48
|
+
set: (value: Record<string, any>) => {
|
|
49
|
+
emit('update:modelValue', value);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const errors = ref<Record<string, string>>({});
|
|
54
|
+
const touched = ref<Record<string, boolean>>({});
|
|
55
|
+
|
|
56
|
+
// Get widget type from UI schema or infer from field schema
|
|
57
|
+
function getWidget(fieldName: string): string {
|
|
58
|
+
const uiSchemaField = props.uiSchema[fieldName];
|
|
59
|
+
if (uiSchemaField?.['ui:widget']) {
|
|
60
|
+
return uiSchemaField['ui:widget'];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fieldSchema = props.schema.properties?.[fieldName];
|
|
64
|
+
if (!fieldSchema) return 'text';
|
|
65
|
+
|
|
66
|
+
// Infer widget from schema
|
|
67
|
+
if (fieldSchema.type === 'boolean') return 'toggle';
|
|
68
|
+
if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') {
|
|
69
|
+
// Only use slider if explicitly specified in uiSchema
|
|
70
|
+
// Don't auto-infer from min/max presence
|
|
71
|
+
return 'number';
|
|
72
|
+
}
|
|
73
|
+
if (fieldSchema.type === 'string') {
|
|
74
|
+
if (fieldSchema.enum) return 'select';
|
|
75
|
+
if (fieldSchema.format === 'email') return 'email';
|
|
76
|
+
if (fieldSchema.format === 'uri') return 'url';
|
|
77
|
+
if (fieldSchema.minLength && fieldSchema.minLength > 100) return 'textarea';
|
|
78
|
+
}
|
|
79
|
+
if (fieldSchema.type === 'array') return 'array';
|
|
80
|
+
if (fieldSchema.type === 'object') return 'object';
|
|
81
|
+
|
|
82
|
+
return 'text';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get input type for uiInput component
|
|
86
|
+
function getInputType(fieldName: string): 'text' | 'email' | 'password' | 'number' | 'date' | 'datetime-local' {
|
|
87
|
+
const widget = getWidget(fieldName);
|
|
88
|
+
|
|
89
|
+
if (widget === 'password') return 'password';
|
|
90
|
+
if (widget === 'email') return 'email';
|
|
91
|
+
if (widget === 'number') return 'number';
|
|
92
|
+
if (widget === 'date') return 'date';
|
|
93
|
+
if (widget === 'datetime-local') return 'datetime-local';
|
|
94
|
+
|
|
95
|
+
return 'text';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get field color based on validation state (computed for reactivity)
|
|
99
|
+
function getFieldColor(fieldName: string): 'gray' | 'green' | 'red' | 'yellow' {
|
|
100
|
+
// Check for errors first (most important)
|
|
101
|
+
if (errors.value[fieldName]) return 'red';
|
|
102
|
+
|
|
103
|
+
// If field has been touched and has a valid value, show green
|
|
104
|
+
if (touched.value[fieldName]) {
|
|
105
|
+
const value = formData.value[fieldName];
|
|
106
|
+
if (isRequired(fieldName) && value !== undefined && value !== null && value !== '') {
|
|
107
|
+
return 'green';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Default to gray
|
|
112
|
+
return 'gray';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if field is required
|
|
116
|
+
function isRequired(fieldName: string): boolean {
|
|
117
|
+
return props.schema.required?.includes(fieldName) ?? false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get field label
|
|
121
|
+
function getFieldLabel(fieldName: string): string {
|
|
122
|
+
const fieldSchema = props.schema.properties?.[fieldName];
|
|
123
|
+
return fieldSchema?.title || fieldName;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Get field description
|
|
127
|
+
function getFieldDescription(fieldName: string): string {
|
|
128
|
+
const fieldSchema = props.schema.properties?.[fieldName];
|
|
129
|
+
return fieldSchema?.description || '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Get placeholder from UI schema
|
|
133
|
+
function getPlaceholder(fieldName: string): string {
|
|
134
|
+
return props.uiSchema[fieldName]?.['ui:placeholder'] || '';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Get autocomplete attribute for better UX (especially for passwords)
|
|
138
|
+
// Following https://www.chromium.org/developers/design-documents/create-amazing-password-forms/
|
|
139
|
+
function getAutocomplete(fieldName: string): string | undefined {
|
|
140
|
+
const widget = getWidget(fieldName);
|
|
141
|
+
const fieldSchema = props.schema.properties?.[fieldName];
|
|
142
|
+
|
|
143
|
+
// Check if explicitly set in uiSchema
|
|
144
|
+
if (props.uiSchema[fieldName]?.['ui:autocomplete']) {
|
|
145
|
+
return props.uiSchema[fieldName]['ui:autocomplete'];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Auto-detect based on field name and type
|
|
149
|
+
const lowerFieldName = fieldName.toLowerCase();
|
|
150
|
+
|
|
151
|
+
if (widget === 'email' || fieldSchema?.format === 'email') {
|
|
152
|
+
return 'email';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (widget === 'password') {
|
|
156
|
+
// Check for common password field patterns
|
|
157
|
+
if (lowerFieldName.includes('new') || lowerFieldName.includes('confirm')) {
|
|
158
|
+
return 'new-password';
|
|
159
|
+
}
|
|
160
|
+
if (lowerFieldName.includes('current') || lowerFieldName === 'password') {
|
|
161
|
+
return 'current-password';
|
|
162
|
+
}
|
|
163
|
+
return 'current-password'; // Default for password fields
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Common autocomplete values
|
|
167
|
+
if (lowerFieldName.includes('username') || lowerFieldName === 'user') {
|
|
168
|
+
return 'username';
|
|
169
|
+
}
|
|
170
|
+
if (lowerFieldName.includes('name')) {
|
|
171
|
+
if (lowerFieldName.includes('first')) return 'given-name';
|
|
172
|
+
if (lowerFieldName.includes('last')) return 'family-name';
|
|
173
|
+
return 'name';
|
|
174
|
+
}
|
|
175
|
+
if (lowerFieldName.includes('phone') || lowerFieldName.includes('tel')) {
|
|
176
|
+
return 'tel';
|
|
177
|
+
}
|
|
178
|
+
if (lowerFieldName.includes('address')) {
|
|
179
|
+
return 'street-address';
|
|
180
|
+
}
|
|
181
|
+
if (lowerFieldName.includes('city')) {
|
|
182
|
+
return 'address-level2';
|
|
183
|
+
}
|
|
184
|
+
if (lowerFieldName.includes('country')) {
|
|
185
|
+
return 'country-name';
|
|
186
|
+
}
|
|
187
|
+
if (lowerFieldName.includes('postal') || lowerFieldName.includes('zip')) {
|
|
188
|
+
return 'postal-code';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Validate form data against schema using AJV
|
|
195
|
+
function validate(): Array<{ field: string; message: string }> {
|
|
196
|
+
const validationErrors: Array<{ field: string; message: string }> = [];
|
|
197
|
+
errors.value = {};
|
|
198
|
+
|
|
199
|
+
// Compile and validate using AJV
|
|
200
|
+
const validateFn = ajv.compile(props.schema);
|
|
201
|
+
const valid = validateFn(formData.value);
|
|
202
|
+
|
|
203
|
+
if (!valid && validateFn.errors) {
|
|
204
|
+
for (const error of validateFn.errors) {
|
|
205
|
+
// Extract field name from instancePath (e.g., "/fieldName" -> "fieldName")
|
|
206
|
+
const field = error.instancePath.replace(/^\//, '') || error.params.missingProperty || 'form';
|
|
207
|
+
|
|
208
|
+
// Generate user-friendly error message
|
|
209
|
+
let message = '';
|
|
210
|
+
|
|
211
|
+
if (error.keyword === 'required') {
|
|
212
|
+
const missingField = error.params.missingProperty;
|
|
213
|
+
message = `${getFieldLabel(missingField)} is required`;
|
|
214
|
+
errors.value[missingField] = message;
|
|
215
|
+
validationErrors.push({ field: missingField, message });
|
|
216
|
+
} else if (error.keyword === 'type') {
|
|
217
|
+
message = `${getFieldLabel(field)} must be a ${error.params.type}`;
|
|
218
|
+
errors.value[field] = message;
|
|
219
|
+
validationErrors.push({ field, message });
|
|
220
|
+
} else if (error.keyword === 'minimum') {
|
|
221
|
+
message = `${getFieldLabel(field)} must be at least ${error.params.limit}`;
|
|
222
|
+
errors.value[field] = message;
|
|
223
|
+
validationErrors.push({ field, message });
|
|
224
|
+
} else if (error.keyword === 'maximum') {
|
|
225
|
+
message = `${getFieldLabel(field)} must be at most ${error.params.limit}`;
|
|
226
|
+
errors.value[field] = message;
|
|
227
|
+
validationErrors.push({ field, message });
|
|
228
|
+
} else if (error.keyword === 'minLength') {
|
|
229
|
+
message = `${getFieldLabel(field)} must be at least ${error.params.limit} characters`;
|
|
230
|
+
errors.value[field] = message;
|
|
231
|
+
validationErrors.push({ field, message });
|
|
232
|
+
} else if (error.keyword === 'maxLength') {
|
|
233
|
+
message = `${getFieldLabel(field)} must be at most ${error.params.limit} characters`;
|
|
234
|
+
errors.value[field] = message;
|
|
235
|
+
validationErrors.push({ field, message });
|
|
236
|
+
} else if (error.keyword === 'format') {
|
|
237
|
+
message = `${getFieldLabel(field)} must be a valid ${error.params.format}`;
|
|
238
|
+
errors.value[field] = message;
|
|
239
|
+
validationErrors.push({ field, message });
|
|
240
|
+
} else if (error.keyword === 'enum') {
|
|
241
|
+
message = `${getFieldLabel(field)} must be one of: ${error.params.allowedValues.join(', ')}`;
|
|
242
|
+
errors.value[field] = message;
|
|
243
|
+
validationErrors.push({ field, message });
|
|
244
|
+
} else {
|
|
245
|
+
// Generic error message
|
|
246
|
+
message = error.message || 'Invalid value';
|
|
247
|
+
errors.value[field] = message;
|
|
248
|
+
validationErrors.push({ field, message });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return validationErrors;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Handle form submission
|
|
257
|
+
function handleSubmit(event: Event) {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
|
|
260
|
+
const validationErrors = validate();
|
|
261
|
+
if (validationErrors.length > 0) {
|
|
262
|
+
emit('validation-error', validationErrors);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
emit('submit', formData.value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Update field value
|
|
270
|
+
function updateField(field: string, value: any) {
|
|
271
|
+
touched.value[field] = true;
|
|
272
|
+
|
|
273
|
+
// Update formData by emitting the new value
|
|
274
|
+
const newData = {
|
|
275
|
+
...formData.value,
|
|
276
|
+
[field]: value
|
|
277
|
+
};
|
|
278
|
+
emit('update:modelValue', newData);
|
|
279
|
+
|
|
280
|
+
// Validate field on change with the new value
|
|
281
|
+
validateFieldWithValue(field, value);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Validate a single field using AJV (reads from formData)
|
|
285
|
+
function validateField(fieldName: string) {
|
|
286
|
+
validateFieldWithValue(fieldName, formData.value[fieldName]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Validate a single field with a specific value
|
|
290
|
+
function validateFieldWithValue(fieldName: string, fieldValue: any) {
|
|
291
|
+
const fieldSchema = props.schema.properties?.[fieldName];
|
|
292
|
+
if (!fieldSchema) return;
|
|
293
|
+
|
|
294
|
+
// Clear existing error
|
|
295
|
+
delete errors.value[fieldName];
|
|
296
|
+
|
|
297
|
+
// If field is empty/null/undefined and required, show required error
|
|
298
|
+
if (isRequired(fieldName) && (fieldValue === undefined || fieldValue === null || fieldValue === '')) {
|
|
299
|
+
errors.value[fieldName] = `${getFieldLabel(fieldName)} is required`;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// If field is empty and not required, no validation needed
|
|
304
|
+
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Create a mini schema for just this field (without required)
|
|
309
|
+
const singleFieldSchema = {
|
|
310
|
+
type: 'object',
|
|
311
|
+
properties: {
|
|
312
|
+
[fieldName]: fieldSchema
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Validate just this field
|
|
317
|
+
const validateFn = ajv.compile(singleFieldSchema);
|
|
318
|
+
const valid = validateFn({ [fieldName]: fieldValue });
|
|
319
|
+
|
|
320
|
+
if (!valid && validateFn.errors) {
|
|
321
|
+
for (const error of validateFn.errors) {
|
|
322
|
+
let message = '';
|
|
323
|
+
|
|
324
|
+
if (error.keyword === 'type') {
|
|
325
|
+
message = `${getFieldLabel(fieldName)} must be a ${error.params.type}`;
|
|
326
|
+
} else if (error.keyword === 'minimum') {
|
|
327
|
+
message = `${getFieldLabel(fieldName)} must be at least ${error.params.limit}`;
|
|
328
|
+
} else if (error.keyword === 'maximum') {
|
|
329
|
+
message = `${getFieldLabel(fieldName)} must be at most ${error.params.limit}`;
|
|
330
|
+
} else if (error.keyword === 'minLength') {
|
|
331
|
+
message = `${getFieldLabel(fieldName)} must be at least ${error.params.limit} characters`;
|
|
332
|
+
} else if (error.keyword === 'maxLength') {
|
|
333
|
+
message = `${getFieldLabel(fieldName)} must be at most ${error.params.limit} characters`;
|
|
334
|
+
} else if (error.keyword === 'format') {
|
|
335
|
+
message = `${getFieldLabel(fieldName)} must be a valid ${error.params.format}`;
|
|
336
|
+
} else if (error.keyword === 'enum') {
|
|
337
|
+
message = `${getFieldLabel(fieldName)} must be one of: ${error.params.allowedValues.join(', ')}`;
|
|
338
|
+
} else {
|
|
339
|
+
message = error.message || 'Invalid value';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
errors.value[fieldName] = message;
|
|
343
|
+
break; // Only show first error per field
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Get enum options for select
|
|
349
|
+
function getSelectOptions(fieldName: string) {
|
|
350
|
+
const fieldSchema = props.schema.properties?.[fieldName];
|
|
351
|
+
if (!fieldSchema?.enum) return [];
|
|
352
|
+
|
|
353
|
+
return fieldSchema.enum.map((value: any) => ({
|
|
354
|
+
id: String(value),
|
|
355
|
+
name: String(value),
|
|
356
|
+
label: String(value)
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Get selected option for select
|
|
361
|
+
function getSelectedOption(fieldName: string) {
|
|
362
|
+
const value = formData.value[fieldName];
|
|
363
|
+
if (!value) {
|
|
364
|
+
// Return first option as default if available
|
|
365
|
+
const options = getSelectOptions(fieldName);
|
|
366
|
+
return options.length > 0 ? options[0] : { id: '', name: '', label: '' };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
id: String(value),
|
|
371
|
+
name: String(value),
|
|
372
|
+
label: String(value)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Array field helpers
|
|
377
|
+
function addArrayItem(fieldName: string) {
|
|
378
|
+
const currentArray = formData.value[fieldName] || [];
|
|
379
|
+
updateField(fieldName, [...currentArray, '']);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function removeArrayItem(fieldName: string, index: number) {
|
|
383
|
+
const currentArray = formData.value[fieldName] || [];
|
|
384
|
+
const newArray = currentArray.filter((_: any, i: number) => i !== index);
|
|
385
|
+
updateField(fieldName, newArray);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function updateArrayItem(fieldName: string, index: number, value: any) {
|
|
389
|
+
const currentArray = formData.value[fieldName] || [];
|
|
390
|
+
const newArray = [...currentArray];
|
|
391
|
+
newArray[index] = value;
|
|
392
|
+
updateField(fieldName, newArray);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Expose methods
|
|
396
|
+
defineExpose({
|
|
397
|
+
validate,
|
|
398
|
+
reset: () => {
|
|
399
|
+
emit('update:modelValue', {});
|
|
400
|
+
errors.value = {};
|
|
401
|
+
touched.value = {};
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
</script>
|
|
405
|
+
|
|
406
|
+
<template>
|
|
407
|
+
<form @submit="handleSubmit" class="space-y-6">
|
|
408
|
+
<!-- Form Title and Description -->
|
|
409
|
+
<div v-if="schema.title || schema.description" class="mb-6">
|
|
410
|
+
<h2 v-if="schema.title" class="text-2xl font-bold text-gray-900 mb-2">
|
|
411
|
+
{{ schema.title }}
|
|
412
|
+
</h2>
|
|
413
|
+
<p v-if="schema.description" class="text-gray-600">
|
|
414
|
+
{{ schema.description }}
|
|
415
|
+
</p>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<!-- Form Fields -->
|
|
419
|
+
<div v-if="schema.properties" class="space-y-6">
|
|
420
|
+
<div
|
|
421
|
+
v-for="(fieldSchema, fieldName) in schema.properties"
|
|
422
|
+
:key="fieldName"
|
|
423
|
+
>
|
|
424
|
+
<!-- Text/Email/URL/Password Input -->
|
|
425
|
+
<uiInput
|
|
426
|
+
v-if="['text', 'email', 'url', 'password'].includes(getWidget(fieldName))"
|
|
427
|
+
:name="fieldName"
|
|
428
|
+
:label="getFieldLabel(fieldName)"
|
|
429
|
+
:type="getInputType(fieldName)"
|
|
430
|
+
:value="formData[fieldName] || ''"
|
|
431
|
+
:placeholder="getPlaceholder(fieldName)"
|
|
432
|
+
:error="errors[fieldName]"
|
|
433
|
+
:color="getFieldColor(fieldName)"
|
|
434
|
+
:loading="loading"
|
|
435
|
+
:required-text="isRequired(fieldName) ? '*' : undefined"
|
|
436
|
+
@input-changed="updateField(fieldName, $event.value)"
|
|
437
|
+
/>
|
|
438
|
+
|
|
439
|
+
<!-- Number Input -->
|
|
440
|
+
<uiInput
|
|
441
|
+
v-else-if="getWidget(fieldName) === 'number'"
|
|
442
|
+
:name="fieldName"
|
|
443
|
+
:label="getFieldLabel(fieldName)"
|
|
444
|
+
type="number"
|
|
445
|
+
:value="String(formData[fieldName] ?? '')"
|
|
446
|
+
:placeholder="getPlaceholder(fieldName)"
|
|
447
|
+
:error="errors[fieldName]"
|
|
448
|
+
:color="getFieldColor(fieldName)"
|
|
449
|
+
:loading="loading"
|
|
450
|
+
:required-text="isRequired(fieldName) ? '*' : undefined"
|
|
451
|
+
:min="schema.properties?.[fieldName]?.minimum"
|
|
452
|
+
:max="schema.properties?.[fieldName]?.maximum"
|
|
453
|
+
@input-changed="updateField(fieldName, $event.value === '' ? null : Number($event.value))"
|
|
454
|
+
/>
|
|
455
|
+
|
|
456
|
+
<!-- Textarea -->
|
|
457
|
+
<uiTextArea
|
|
458
|
+
v-else-if="getWidget(fieldName) === 'textarea'"
|
|
459
|
+
:name="fieldName"
|
|
460
|
+
:label="getFieldLabel(fieldName)"
|
|
461
|
+
:value="formData[fieldName] || ''"
|
|
462
|
+
:placeholder="getPlaceholder(fieldName)"
|
|
463
|
+
:error="errors[fieldName]"
|
|
464
|
+
:color="getFieldColor(fieldName)"
|
|
465
|
+
:loading="loading"
|
|
466
|
+
:required-text="isRequired(fieldName) ? '*' : undefined"
|
|
467
|
+
:rows="4"
|
|
468
|
+
@input-changed="updateField(fieldName, $event.value)"
|
|
469
|
+
/>
|
|
470
|
+
|
|
471
|
+
<!-- Select Dropdown -->
|
|
472
|
+
<uiSelect
|
|
473
|
+
v-else-if="getWidget(fieldName) === 'select'"
|
|
474
|
+
:label="getFieldLabel(fieldName)"
|
|
475
|
+
:items="getSelectOptions(fieldName)"
|
|
476
|
+
:select="getSelectedOption(fieldName)"
|
|
477
|
+
:placeholder="getPlaceholder(fieldName) || 'Select an option'"
|
|
478
|
+
:error="errors[fieldName]"
|
|
479
|
+
:color="getFieldColor(fieldName)"
|
|
480
|
+
:loading="loading"
|
|
481
|
+
:required-text="isRequired(fieldName) ? '*' : undefined"
|
|
482
|
+
@select-changed="(item: any) => updateField(fieldName, item.id)"
|
|
483
|
+
/>
|
|
484
|
+
|
|
485
|
+
<!-- Toggle (Boolean) -->
|
|
486
|
+
<uiToggle
|
|
487
|
+
v-else-if="getWidget(fieldName) === 'toggle'"
|
|
488
|
+
:item="{
|
|
489
|
+
title: getFieldLabel(fieldName),
|
|
490
|
+
subtitle: getFieldDescription(fieldName),
|
|
491
|
+
action: fieldName
|
|
492
|
+
}"
|
|
493
|
+
:checked="formData[fieldName]"
|
|
494
|
+
:loading="loading"
|
|
495
|
+
@toggle-changed="updateField(fieldName, $event.active)"
|
|
496
|
+
/>
|
|
497
|
+
|
|
498
|
+
<!-- Checkbox (Boolean alternative) -->
|
|
499
|
+
<div v-else-if="getWidget(fieldName) === 'checkbox'" class="flex items-start">
|
|
500
|
+
<input
|
|
501
|
+
:id="fieldName"
|
|
502
|
+
type="checkbox"
|
|
503
|
+
:checked="formData[fieldName]"
|
|
504
|
+
:disabled="loading"
|
|
505
|
+
@change="updateField(fieldName, ($event.target as HTMLInputElement).checked)"
|
|
506
|
+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-1"
|
|
507
|
+
/>
|
|
508
|
+
<label :for="fieldName" class="ml-2 block">
|
|
509
|
+
<span class="text-sm font-medium text-gray-700">
|
|
510
|
+
{{ getFieldLabel(fieldName) }}
|
|
511
|
+
<span v-if="isRequired(fieldName)" class="text-red-500">*</span>
|
|
512
|
+
</span>
|
|
513
|
+
<p v-if="getFieldDescription(fieldName)" class="text-sm text-gray-500">
|
|
514
|
+
{{ getFieldDescription(fieldName) }}
|
|
515
|
+
</p>
|
|
516
|
+
</label>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<!-- Range Slider -->
|
|
520
|
+
<uiRangeSlider
|
|
521
|
+
v-else-if="getWidget(fieldName) === 'slider'"
|
|
522
|
+
:label="getFieldLabel(fieldName)"
|
|
523
|
+
:min="schema.properties[fieldName].minimum ?? 0"
|
|
524
|
+
:max="schema.properties[fieldName].maximum ?? 100"
|
|
525
|
+
:slider-value="formData[fieldName] ?? schema.properties[fieldName].minimum ?? 0"
|
|
526
|
+
:loading="loading"
|
|
527
|
+
:required-text="isRequired(fieldName) ? '*' : undefined"
|
|
528
|
+
@sliderUpdated="updateField(fieldName, $event)"
|
|
529
|
+
/>
|
|
530
|
+
|
|
531
|
+
<!-- Array (Simple list) -->
|
|
532
|
+
<div v-else-if="getWidget(fieldName) === 'array'" class="space-y-2">
|
|
533
|
+
<label class="block text-sm font-medium text-gray-700">
|
|
534
|
+
{{ getFieldLabel(fieldName) }}
|
|
535
|
+
<span v-if="isRequired(fieldName)" class="text-red-500">*</span>
|
|
536
|
+
</label>
|
|
537
|
+
<p v-if="getFieldDescription(fieldName)" class="text-sm text-gray-500 mb-2">
|
|
538
|
+
{{ getFieldDescription(fieldName) }}
|
|
539
|
+
</p>
|
|
540
|
+
<div v-if="errors[fieldName]" class="text-sm text-red-600 mb-2">
|
|
541
|
+
{{ errors[fieldName] }}
|
|
542
|
+
</div>
|
|
543
|
+
<div class="space-y-2">
|
|
544
|
+
<div
|
|
545
|
+
v-for="(item, index) in (formData[fieldName] || [])"
|
|
546
|
+
:key="index"
|
|
547
|
+
class="flex gap-2"
|
|
548
|
+
>
|
|
549
|
+
<input
|
|
550
|
+
type="text"
|
|
551
|
+
:value="item"
|
|
552
|
+
:disabled="loading"
|
|
553
|
+
@input="updateArrayItem(fieldName, index, ($event.target as HTMLInputElement).value)"
|
|
554
|
+
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
555
|
+
/>
|
|
556
|
+
<button
|
|
557
|
+
type="button"
|
|
558
|
+
@click="removeArrayItem(fieldName, index)"
|
|
559
|
+
:disabled="loading"
|
|
560
|
+
class="px-3 py-2 text-red-600 hover:text-red-800 border border-red-300 rounded-md"
|
|
561
|
+
>
|
|
562
|
+
Remove
|
|
563
|
+
</button>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
@click="addArrayItem(fieldName)"
|
|
569
|
+
:disabled="loading"
|
|
570
|
+
class="mt-2 px-4 py-2 text-sm text-blue-600 hover:text-blue-800 border border-blue-300 rounded-md"
|
|
571
|
+
>
|
|
572
|
+
+ Add Item
|
|
573
|
+
</button>
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
<!-- Nested Object (Simple rendering) -->
|
|
577
|
+
<div v-else-if="getWidget(fieldName) === 'object' || getWidget(fieldName) === 'card'" class="border border-gray-200 rounded-lg p-4 space-y-4">
|
|
578
|
+
<div>
|
|
579
|
+
<h3 class="text-lg font-semibold text-gray-900 mb-1">
|
|
580
|
+
{{ getFieldLabel(fieldName) }}
|
|
581
|
+
</h3>
|
|
582
|
+
<p v-if="getFieldDescription(fieldName)" class="text-sm text-gray-600 mb-4">
|
|
583
|
+
{{ getFieldDescription(fieldName) }}
|
|
584
|
+
</p>
|
|
585
|
+
</div>
|
|
586
|
+
<div class="space-y-4 pl-4 border-l-2 border-gray-200">
|
|
587
|
+
<!-- Recursively render nested fields -->
|
|
588
|
+
<div
|
|
589
|
+
v-for="(subSchema, subName) in fieldSchema.properties"
|
|
590
|
+
:key="subName"
|
|
591
|
+
>
|
|
592
|
+
<!-- Nested field rendering would go here -->
|
|
593
|
+
<p class="text-sm text-gray-500">
|
|
594
|
+
Nested field: {{ subName }} (type: {{ subSchema.type }})
|
|
595
|
+
</p>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
|
|
602
|
+
<!-- Form Actions -->
|
|
603
|
+
<div class="flex justify-end gap-3 pt-6 border-t">
|
|
604
|
+
<slot name="actions">
|
|
605
|
+
<uiButton
|
|
606
|
+
type="submit"
|
|
607
|
+
color="primary"
|
|
608
|
+
:loading="loading"
|
|
609
|
+
>
|
|
610
|
+
Submit
|
|
611
|
+
</uiButton>
|
|
612
|
+
</slot>
|
|
613
|
+
</div>
|
|
614
|
+
</form>
|
|
615
|
+
</template>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Data components
|
|
2
|
+
export * from './data';
|
|
3
|
+
|
|
4
|
+
// Form components
|
|
5
|
+
export * from './forms';
|
|
6
|
+
|
|
7
|
+
// Navigation components
|
|
8
|
+
export * from './navigation';
|
|
9
|
+
|
|
10
|
+
// Overlay components
|
|
11
|
+
export * from './overlays';
|
|
12
|
+
|
|
13
|
+
// Domain components
|
|
14
|
+
export * from './domain';
|
|
15
|
+
|
|
16
|
+
// Composables
|
|
17
|
+
export * from './composables';
|