1212 lines
29 KiB
Vue

<template>
<form class="dynamic-form">
<div class="form-container">
<div class="form-row">
<div
v-for="field in fields"
:key="field.name"
:class="getFieldColumnClasses(field)"
class="form-field"
>
<!-- Text Input -->
<div v-if="field.type === 'text'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<InputText
:id="field.name"
v-model="fieldValues[field.name]"
:type="field.format || 'text'"
:placeholder="field.placeholder"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:invalid="!!getFieldError(field.name)"
fluid
:maxlength="field.maxLength"
:inputmode="field.inputMode"
:pattern="field.pattern"
@keydown="handleKeyDown(field, $event)"
@input="handleTextInput(field, $event)"
@blur="
handleFieldBlur(
field,
$event.target ? $event.target.value : fieldValues[field.name],
)
"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- Number Input -->
<div v-else-if="field.type === 'number'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<InputNumber
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:min="field.min"
:max="field.max"
:step="field.step"
:invalid="!!getFieldError(field.name)"
fluid
@input="handleFieldChange(field, $event.value)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- Textarea -->
<div v-else-if="field.type === 'textarea'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<Textarea
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:rows="field.rows || 3"
:invalid="!!getFieldError(field.name)"
fluid
:autoResize="field.autoResize !== false"
@input="
handleFieldChange(
field,
$event.target ? $event.target.value : $event,
)
"
@blur="
handleFieldBlur(
field,
$event.target ? $event.target.value : fieldValues[field.name],
)
"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- Select Dropdown -->
<div v-else-if="field.type === 'select'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<Select
:id="field.name"
v-model="fieldValues[field.name]"
:options="field.options"
optionLabel="label"
optionValue="value"
:disabled="field.disabled || isFormDisabled"
:placeholder="field.placeholder"
:invalid="!!getFieldError(field.name)"
fluid
appendTo="body"
@update:model-value="handleFieldChange(field, $event)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- AutoComplete Input (NEW!) -->
<div v-else-if="field.type === 'autocomplete'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<AutoComplete
:id="field.name"
v-model="fieldValues[field.name]"
:suggestions="field.filteredOptions || field.options || []"
:disabled="field.disabled || isFormDisabled"
:placeholder="field.placeholder"
:invalid="!!getFieldError(field.name)"
fluid
:dropdown="field.dropdown"
:forceSelection="field.forceSelection"
appendTo="body"
@complete="handleAutocompleteSearch(field, $event)"
@update:model-value="handleFieldChange(field, $event)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- Checkbox -->
<div v-else-if="field.type === 'checkbox'" class="field-wrapper">
<div class="checkbox-container">
<Checkbox
:id="field.name"
v-model="fieldValues[field.name]"
:binary="true"
:disabled="field.disabled || isFormDisabled"
:invalid="!!getFieldError(field.name)"
@update:model-value="handleFieldChange(field, $event)"
/>
<label v-if="field.label" :for="field.name" class="checkbox-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
</div>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- Radio Group -->
<div v-else-if="field.type === 'radio'" class="field-wrapper">
<label v-if="field.label" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<div class="radio-group">
<div
v-for="option in field.options"
:key="option.value"
class="radio-option"
>
<RadioButton
:id="`${field.name}_${option.value}`"
v-model="fieldValues[field.name]"
:name="field.name"
:value="option.value"
:disabled="field.disabled || isFormDisabled"
:invalid="!!getFieldError(field.name)"
@update:model-value="handleFieldChange(field, $event)"
/>
<label :for="`${field.name}_${option.value}`" class="radio-label">
{{ option.label }}
</label>
</div>
</div>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- Date Input -->
<div v-else-if="field.type === 'date'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<DatePicker
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:minDate="field.minDate"
:maxDate="field.maxDate"
:invalid="!!getFieldError(field.name)"
fluid
showIcon
iconDisplay="input"
:dateFormat="field.dateFormat || 'dd/mm/yy'"
@update:model-value="handleFieldChange(field, $event)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- DateTime Input -->
<div v-else-if="field.type === 'datetime'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<DatePicker
:id="field.name"
v-model="fieldValues[field.name]"
:placeholder="field.placeholder"
:disabled="field.disabled || isFormDisabled"
:readonly="field.readonly"
:minDate="field.minDate"
:maxDate="field.maxDate"
:invalid="!!getFieldError(field.name)"
fluid
showIcon
iconDisplay="input"
showTime
:hourFormat="field.hourFormat || '24'"
:dateFormat="field.dateFormat || 'dd/mm/yy'"
@update:model-value="handleFieldChange(field, $event)"
@blur="handleFieldBlur(field, fieldValues[field.name])"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
<!-- File Input -->
<div v-else-if="field.type === 'file'" class="field-wrapper">
<label v-if="field.label" :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<FileUpload
:id="field.name"
v-model="fieldValues[field.name]"
mode="basic"
:disabled="field.disabled || isFormDisabled"
:accept="field.accept"
:multiple="field.multiple"
:invalidFileTypeMessage="`Invalid file type. Accepted types: ${field.accept || 'any'}`"
:maxFileSize="field.maxFileSize"
:invalidFileSizeMessage="`File size exceeds the limit of ${field.maxFileSize} bytes`"
:chooseLabel="field.chooseLabel || 'Choose File'"
:showUploadButton="false"
:showCancelButton="false"
auto
@upload="handleFieldChange(field, $event.files)"
@select="handleFieldChange(field, $event.files)"
/>
<small v-if="field.helpText" class="field-help">{{
field.helpText
}}</small>
<Message
v-if="getFieldError(field.name)"
severity="error"
size="small"
variant="simple"
>
{{ getFieldError(field.name) }}
</Message>
</div>
</div>
</div>
<!-- Submit/Action Buttons -->
<div v-if="showSubmitButton || showCancelButton" class="form-buttons">
<Button
v-if="showSubmitButton"
type="button"
:label="submitButtonText"
:loading="isLoading"
:disabled="isFormDisabled"
severity="primary"
@click="handleSubmit"
/>
<Button
v-if="showCancelButton"
type="button"
:label="cancelButtonText"
:disabled="isLoading"
severity="secondary"
variant="outlined"
@click="handleCancel"
/>
</div>
</div>
</form>
</template>
<script setup>
import { ref, reactive, computed, watch, defineEmits, defineProps } from "vue";
// PrimeVue Components
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import Checkbox from "primevue/checkbox";
import RadioButton from "primevue/radiobutton";
import DatePicker from "primevue/datepicker";
import FileUpload from "primevue/fileupload";
import Button from "primevue/button";
import Message from "primevue/message";
import { useLoadingStore } from "../../stores/loading";
const loadingStore = useLoadingStore();
const props = defineProps({
fields: {
type: Array,
required: true,
},
formData: {
type: Object,
required: false,
default: null,
},
onChange: {
type: Function,
required: false,
default: null,
},
onSubmit: {
type: Function,
required: false,
default: null,
},
showSubmitButton: {
type: Boolean,
default: true,
},
showCancelButton: {
type: Boolean,
default: false,
},
submitButtonText: {
type: String,
default: "Submit",
},
cancelButtonText: {
type: String,
default: "Cancel",
},
validateOnChange: {
type: Boolean,
default: true,
},
validateOnBlur: {
type: Boolean,
default: true,
},
validateOnSubmit: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: "Processing...",
},
disableOnLoading: {
type: Boolean,
default: true,
},
// Auto-connect to global loading store
useGlobalLoading: {
type: Boolean,
default: true,
},
formName: {
type: String,
default: "form",
},
});
const emit = defineEmits(["update:formData", "submit", "cancel", "change", "blur"]);
// Internal form state (used when no external formData is provided)
const internalFormData = reactive({});
const formErrors = reactive({});
const isSubmitting = ref(false);
// Computed loading and disabled states
const isLoading = computed(() => {
if (props.useGlobalLoading) {
return (
props.loading ||
loadingStore.getComponentLoading("form") ||
loadingStore.getComponentLoading(props.formName) ||
isSubmitting.value
);
}
return props.loading || isSubmitting.value;
});
const isFormDisabled = computed(() => {
return props.disableOnLoading && isLoading.value;
});
// Computed property for v-model binding
const fieldValues = computed({
get() {
return props.formData || internalFormData;
},
set(newValues) {
if (props.formData) {
Object.assign(props.formData, newValues);
emit("update:formData", props.formData);
} else {
Object.assign(internalFormData, newValues);
}
},
});
// Initialize form data
const initializeFormData = () => {
props.fields.forEach((field) => {
if (props.formData) {
// If external formData is provided, ensure all fields exist
if (!(field.name in props.formData)) {
props.formData[field.name] = getDefaultValue(field);
}
} else {
// Use internal form data
internalFormData[field.name] = getDefaultValue(field);
}
});
};
// Get default value for a field based on its type
const getDefaultValue = (field) => {
switch (field.type) {
case "checkbox":
return field.defaultValue !== undefined ? field.defaultValue : false;
case "number":
return field.defaultValue !== undefined ? field.defaultValue : "";
case "select":
case "radio":
return field.defaultValue !== undefined ? field.defaultValue : "";
case "file":
return null;
default:
return field.defaultValue !== undefined ? field.defaultValue : "";
}
};
// Get the current value for a field
const getFieldValue = (fieldName) => {
return fieldValues.value[fieldName];
};
// Get error for a field
const getFieldError = (fieldName) => {
return formErrors[fieldName];
};
// Validate a single field
const validateField = (field, value) => {
const errors = [];
// Required validation (only check if field is required and value is empty)
if (field.required && (value === "" || value === null || value === undefined)) {
errors.push(`${field.label} is required`);
return errors[0]; // Return early if required field is empty
}
// Skip other validations if value is empty and field is not required
if (!value && !field.required) {
return null;
}
// Email validation (more comprehensive)
if (field.format === "email" && value) {
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!emailRegex.test(value)) {
errors.push("Please enter a valid email address");
}
}
// Phone validation (for tel format)
if (field.format === "tel" && value) {
// Allow various phone formats
const phoneRegex =
/^[\+]?[1-9][\d]{0,15}$|^\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}$|^[0-9]{10}$/;
if (!phoneRegex.test(value.replace(/[\s\-\(\)\.]/g, ""))) {
// Use custom validation if provided, otherwise use default
if (!field.validate) {
errors.push("Please enter a valid phone number");
}
}
}
// URL validation
if (field.format === "url" && value) {
try {
new URL(value);
} catch {
errors.push("Please enter a valid URL");
}
}
// Min/Max length validation for text
if ((field.type === "text" || field.type === "textarea") && value) {
if (field.minLength && value.length < field.minLength) {
errors.push(`${field.label} must be at least ${field.minLength} characters`);
}
if (field.maxLength && value.length > field.maxLength) {
errors.push(`${field.label} must not exceed ${field.maxLength} characters`);
}
}
// Min/Max for numbers
if (field.type === "number" && value !== "" && value !== null && value !== undefined) {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
if (field.min !== undefined && numValue < field.min) {
errors.push(`Value must be at least ${field.min}`);
}
if (field.max !== undefined && numValue > field.max) {
errors.push(`Value must not exceed ${field.max}`);
}
} else {
errors.push("Please enter a valid number");
}
}
// Custom validation (always runs last)
if (field.validate && typeof field.validate === "function") {
const customError = field.validate(value);
if (customError) {
errors.push(customError);
}
}
return errors.length > 0 ? errors[0] : null;
};
// Handle keydown events for input restrictions
const handleKeyDown = (field, event) => {
// Check if field has numeric-only restriction
if (field.inputMode === "numeric" || field.pattern === "[0-9]*") {
const key = event.key;
// Allow control keys (backspace, delete, tab, escape, enter, arrows, etc.)
const allowedKeys = [
"Backspace",
"Delete",
"Tab",
"Escape",
"Enter",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
];
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X, Ctrl+Z
if (event.ctrlKey || event.metaKey) {
return;
}
// Allow allowed control keys
if (allowedKeys.includes(key)) {
return;
}
// Only allow numeric keys (0-9)
if (!/^[0-9]$/.test(key)) {
event.preventDefault();
return;
}
// Check max length if specified
if (field.maxLength && event.target.value.length >= field.maxLength) {
event.preventDefault();
return;
}
}
};
// Handle text input with custom formatting
const handleTextInput = (field, event) => {
let value = event.target ? event.target.value : event;
// Apply custom input formatting if provided
if (field.onInput && typeof field.onInput === "function") {
value = field.onInput(value);
// Update the input value immediately to reflect formatting
if (event.target) {
event.target.value = value;
}
}
// Call the standard field change handler
handleFieldChange(field, value);
};
// Handle field value changes
const handleFieldChange = (field, value) => {
// Update form data
if (props.formData) {
props.formData[field.name] = value;
emit("update:formData", props.formData);
} else {
internalFormData[field.name] = value;
}
// Clear previous error for this field
delete formErrors[field.name];
// Validate field if enabled
if (props.validateOnChange) {
const error = validateField(field, value);
if (error) {
formErrors[field.name] = error;
}
}
// Call custom onChange if provided on the field
if (field.onChangeOverride && typeof field.onChangeOverride === "function") {
field.onChangeOverride(value, field.name, getCurrentFormData());
}
// Call global onChange if provided
else if (props.onChange && typeof props.onChange === "function") {
props.onChange(field.name, value, getCurrentFormData());
}
// Emit change event
emit("change", {
fieldName: field.name,
value: value,
formData: getCurrentFormData(),
});
};
// Handle field blur events
const handleFieldBlur = (field, value) => {
// Validate field on blur if enabled
if (props.validateOnBlur) {
const error = validateField(field, value);
if (error) {
formErrors[field.name] = error;
} else {
// Clear error if validation passes
delete formErrors[field.name];
}
}
// Emit blur event
emit("blur", {
fieldName: field.name,
value: value,
formData: getCurrentFormData(),
});
};
// Get current form data (either external or internal)
const getCurrentFormData = () => {
return props.formData || internalFormData;
};
// Handle autocomplete search
const handleAutocompleteSearch = (field, event) => {
if (field.onSearch && typeof field.onSearch === "function") {
// Custom search function provided
field.onSearch(event.query, (results) => {
field.filteredOptions = results;
});
} else if (field.options) {
// Filter existing options
const query = event.query.toLowerCase();
field.filteredOptions = field.options.filter((option) => {
const label =
typeof option === "string" ? option : option[field.optionLabel || "label"];
return label && label.toLowerCase().includes(query);
});
} else {
field.filteredOptions = [];
}
};
// Get field column classes for responsive layout
const getFieldColumnClasses = (field) => {
const classes = [];
// Base column size (mobile-first)
const cols = field.cols || 12;
classes.push(`col-${cols}`);
// Small breakpoint (sm)
if (field.sm && field.sm !== cols) {
classes.push(`sm-${field.sm}`);
}
// Medium breakpoint (md)
if (field.md && field.md !== cols) {
classes.push(`md-${field.md}`);
}
// Large breakpoint (lg)
if (field.lg && field.lg !== cols) {
classes.push(`lg-${field.lg}`);
}
return classes.join(" ");
};
// Validate entire form
const validateForm = () => {
const errors = {};
let isValid = true;
props.fields.forEach((field) => {
const value = getFieldValue(field.name);
const error = validateField(field, value);
if (error) {
errors[field.name] = error;
isValid = false;
}
});
Object.assign(formErrors, errors);
return isValid;
};
// Handle form submission
const handleSubmit = async () => {
// Prevent double submission
if (isSubmitting.value) {
console.warn("Form: submission already in progress, ignoring duplicate submission");
return;
}
// Always validate on submit if enabled
if (props.validateOnSubmit && !validateForm()) {
console.warn("Form: validation failed on submit");
return;
}
isSubmitting.value = true;
try {
const formData = getCurrentFormData();
console.log("Form: emitting submit event with data:", formData);
// Only emit the submit event - let parent handle the actual submission
// This prevents the dual submission pathway issue
emit("submit", formData);
// Call onSubmit prop if provided (for backward compatibility)
if (props.onSubmit && typeof props.onSubmit === "function") {
await props.onSubmit(formData);
}
} catch (error) {
console.error("Form: submission error:", error);
// Reset isSubmitting on error so user can retry
isSubmitting.value = false;
}
// Note: Don't reset isSubmitting.value here in finally - let parent control this
// The parent should call the exposed stopLoading() method when done
};
// Handle cancel action
const handleCancel = () => {
emit("cancel");
};
// Initialize form data when component mounts
initializeFormData();
// Watch for changes in fields to reinitialize
watch(
() => props.fields,
() => {
initializeFormData();
},
{ deep: true },
);
// Clear all form errors
const clearAllErrors = () => {
Object.keys(formErrors).forEach((key) => delete formErrors[key]);
};
// Expose methods for parent component
defineExpose({
validateForm,
getCurrentFormData,
resetForm: () => {
initializeFormData();
clearAllErrors();
},
setFieldError: (fieldName, error) => {
formErrors[fieldName] = error;
},
clearFieldError: (fieldName) => {
delete formErrors[fieldName];
},
clearAllErrors,
// Manual validation trigger
validateField: (fieldName) => {
const field = props.fields.find((f) => f.name === fieldName);
if (field) {
const value = getFieldValue(fieldName);
const error = validateField(field, value);
if (error) {
formErrors[fieldName] = error;
} else {
delete formErrors[fieldName];
}
return !error;
}
return true;
},
// Loading control methods
startLoading: (message) =>
loadingStore.setComponentLoading(props.formName, true, message || props.loadingMessage),
stopLoading: () => loadingStore.setComponentLoading(props.formName, false),
isLoading: () => isLoading.value,
});
</script>
<style scoped>
.dynamic-form {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.form-container {
width: 100%;
}
.form-row {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
}
/* Base column classes (mobile-first) */
.col-1 {
grid-column: span 1;
}
.col-2 {
grid-column: span 2;
}
.col-3 {
grid-column: span 3;
}
.col-4 {
grid-column: span 4;
}
.col-5 {
grid-column: span 5;
}
.col-6 {
grid-column: span 6;
}
.col-7 {
grid-column: span 7;
}
.col-8 {
grid-column: span 8;
}
.col-9 {
grid-column: span 9;
}
.col-10 {
grid-column: span 10;
}
.col-11 {
grid-column: span 11;
}
.col-12 {
grid-column: span 12;
}
/* Small breakpoint (576px and up) */
@media (min-width: 576px) {
.sm-1 {
grid-column: span 1;
}
.sm-2 {
grid-column: span 2;
}
.sm-3 {
grid-column: span 3;
}
.sm-4 {
grid-column: span 4;
}
.sm-5 {
grid-column: span 5;
}
.sm-6 {
grid-column: span 6;
}
.sm-7 {
grid-column: span 7;
}
.sm-8 {
grid-column: span 8;
}
.sm-9 {
grid-column: span 9;
}
.sm-10 {
grid-column: span 10;
}
.sm-11 {
grid-column: span 11;
}
.sm-12 {
grid-column: span 12;
}
}
/* Medium breakpoint (768px and up) */
@media (min-width: 768px) {
.md-1 {
grid-column: span 1;
}
.md-2 {
grid-column: span 2;
}
.md-3 {
grid-column: span 3;
}
.md-4 {
grid-column: span 4;
}
.md-5 {
grid-column: span 5;
}
.md-6 {
grid-column: span 6;
}
.md-7 {
grid-column: span 7;
}
.md-8 {
grid-column: span 8;
}
.md-9 {
grid-column: span 9;
}
.md-10 {
grid-column: span 10;
}
.md-11 {
grid-column: span 11;
}
.md-12 {
grid-column: span 12;
}
}
/* Large breakpoint (992px and up) */
@media (min-width: 992px) {
.lg-1 {
grid-column: span 1;
}
.lg-2 {
grid-column: span 2;
}
.lg-3 {
grid-column: span 3;
}
.lg-4 {
grid-column: span 4;
}
.lg-5 {
grid-column: span 5;
}
.lg-6 {
grid-column: span 6;
}
.lg-7 {
grid-column: span 7;
}
.lg-8 {
grid-column: span 8;
}
.lg-9 {
grid-column: span 9;
}
.lg-10 {
grid-column: span 10;
}
.lg-11 {
grid-column: span 11;
}
.lg-12 {
grid-column: span 12;
}
}
/* Mobile responsive - stack all fields on very small screens */
@media (max-width: 575px) {
.form-field {
grid-column: span 12 !important;
}
}
.field-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.field-label {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color, #374151);
margin-bottom: 0.25rem;
}
.field-help {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
margin-top: 0.25rem;
}
.required {
color: var(--red-500, #ef4444);
margin-left: 0.125rem;
}
/* Checkbox specific styles */
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-label {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-color, #374151);
cursor: pointer;
}
/* Radio group styles */
.radio-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.radio-label {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-color, #374151);
cursor: pointer;
}
/* Form buttons */
.form-buttons {
display: flex;
gap: 0.75rem;
margin-top: 2rem;
justify-content: flex-start;
}
/* Tablet responsive - let the responsive classes handle the layout */
@media (max-width: 767px) {
/* Fields without md specified should span full width on tablets */
.form-field:not([class*="md-"]) {
grid-column: span 12;
}
}
/* Large mobile responsive */
@media (max-width: 640px) {
.form-buttons {
flex-direction: column;
}
.form-buttons :deep(.p-button) {
width: 100%;
}
}
</style>