svarog-ui-core
Version:
Core components of Svarog UI without themes
362 lines (311 loc) • 9.61 kB
JavaScript
// src/components/Form/Form.js
import {
createComponent,
createElement,
appendChildren,
} from '../../utils/componentFactory.js';
import { createBaseComponent } from '../../utils/baseComponent.js';
import { validateRequiredProps } from '../../utils/validation.js';
import { debounce } from '../../utils/performance.js';
// CSS injection imports
import { createStyleInjector } from '../../utils/styleInjection.js';
import { formStyles } from './Form.styles.js';
import { formValidationStyles } from './FormValidation.styles.js';
// Create style injectors for Form component
const injectFormStyles = createStyleInjector('Form');
const injectFormValidationStyles = createStyleInjector('FormValidation');
/**
* Creates a Form component for handling form functionality
* @param {Object} props - Form properties
* @returns {Object} Form component API
*/
const createForm = (props) => {
// Inject styles on component creation
injectFormStyles(formStyles);
injectFormValidationStyles(formValidationStyles);
// Migrate legacy props to standardized props
const migrateLegacyProps = (props) => {
const migrated = { ...props };
// Add loading standardization if needed in the future
// if ('isLoading' in props && !('loading' in props)) {
// console.warn('[Form] isLoading is deprecated, use loading instead');
// migrated.loading = props.isLoading;
// delete migrated.isLoading;
// }
return migrated;
};
// Normalize props - but keep original props for validation
const normalizedProps = migrateLegacyProps(props);
// Validate required props - use original props to maintain backward compatibility with tests
validateRequiredProps(
props,
{
children: { required: true },
layout: {
required: false,
type: 'string',
allowedValues: ['vertical', 'horizontal'],
validator: (layout) => {
if (layout && layout !== 'vertical' && layout !== 'horizontal') {
return 'must be either "vertical" or "horizontal"';
}
return true;
},
},
},
'Form'
);
// Initialize state from normalized props with defaults
const state = {
children: normalizedProps.children,
id: normalizedProps.id,
className: normalizedProps.className || '',
onSubmit: normalizedProps.onSubmit,
onChange: normalizedProps.onChange,
autoValidate:
normalizedProps.autoValidate !== undefined
? normalizedProps.autoValidate
: true,
layout: normalizedProps.layout || 'vertical',
};
// Store registered form fields
const fields = [];
// Define event handlers before using them in createFormElement
/**
* Handles form submission
* @private
* @param {Event} event - The submit event
*/
function handleSubmit(event) {
// Prevent default form submission
event.preventDefault();
// Validate form if autoValidate is true
let isValid = true;
if (state.autoValidate) {
isValid = validate();
}
// Get form data
const formData = getFormData();
// Call onSubmit callback if provided
if (typeof state.onSubmit === 'function') {
state.onSubmit(event, formData, isValid);
}
}
/**
* Handles form change events
* @private
* @param {Event} event - The change event
*/
function handleChange(event) {
// Call onChange callback if provided
if (typeof state.onChange === 'function') {
const formData = getFormData();
state.onChange(event, formData);
}
}
/**
* Handles input events
* @private
* @param {Event} event - The input event
*/
const handleInput = debounce(() => {
// This is mainly for real-time validation if needed
// Currently a stub but can be expanded for real-time validation
// Using _event prefix to indicate intentionally unused parameter
}, 100);
/**
* Creates the form element
* @private
* @param {Object} state - Current state
* @returns {HTMLFormElement} The form element
*/
function createFormElement(state) {
const { id, className, layout, children, autoValidate } = state;
// Create form element
const form = createElement('form', {
classes: ['form', `form--${layout}`, className],
attributes: {
id,
novalidate: autoValidate ? 'novalidate' : null,
},
events: {
submit: handleSubmit,
change: handleChange,
input: handleInput,
},
});
// Add content
appendChildren(form, children);
return form;
}
// Create base component with render function
const component = createBaseComponent((componentState) => {
return createFormElement(componentState);
})(state);
// Get the form element
const formElement = component.getElement();
/**
* Validates all registered form fields
* @returns {boolean} Whether the form is valid
*/
function validate() {
// Validate all registered fields
const fieldResults = fields.map((field) => {
// Skip fields that don't have a validate method
if (typeof field.validate !== 'function') {
return true;
}
return field.validate();
});
// Form is valid if all fields are valid
return fieldResults.every((isValid) => isValid);
}
/**
* Gets the form data
* @returns {Object} Form data as key-value pairs
*/
function getFormData() {
const formData = new FormData(formElement);
const data = {};
// Convert FormData to plain object
for (const [key, value] of formData.entries()) {
// Handle multiple values for the same key (e.g., checkboxes)
if (data[key] !== undefined) {
if (!Array.isArray(data[key])) {
data[key] = [data[key]];
}
data[key].push(value);
} else {
data[key] = value;
}
}
return data;
}
// Public API
return {
/**
* Gets the form element
* @returns {HTMLFormElement} The form element
*/
getElement() {
return formElement;
},
/**
* Registers a form field component
* @param {Object} field - The field component to register
* @returns {Object} Form component for chaining
*/
registerField(field) {
if (field && !fields.includes(field)) {
fields.push(field);
}
return this;
},
/**
* Validates all registered form fields
* @returns {boolean} Whether the form is valid
*/
validate,
/**
* Gets the form data
* @returns {Object} Form data as key-value pairs
*/
getFormData,
/**
* Resets the form
* @returns {Object} Form component for chaining
*/
reset() {
formElement.reset();
// Reset all registered fields with reset method
fields.forEach((field) => {
if (typeof field.reset === 'function') {
field.reset();
}
});
return this;
},
/**
* Sets form values
* @param {Object} values - Object containing form values
* @returns {Object} Form component for chaining
*/
setValues(values) {
if (!values || typeof values !== 'object') {
return this;
}
// Set values for form elements
Object.entries(values).forEach(([name, value]) => {
const elements = formElement.elements[name];
if (!elements) {
return;
}
// Handle radio groups and multiple elements with the same name
if (elements instanceof RadioNodeList) {
Array.from(elements).forEach((element) => {
// Handle checkboxes and radio buttons
if (element.type === 'checkbox' || element.type === 'radio') {
if (Array.isArray(value)) {
element.checked = value.includes(element.value);
} else {
element.checked = element.value === value;
}
} else {
element.value = value;
}
});
} else {
// Handle single elements
if (elements.type === 'checkbox') {
elements.checked = Boolean(value);
} else {
elements.value = value;
}
}
});
// Update registered field components
fields.forEach((field) => {
const name = field.props?.name;
if (
name &&
values[name] !== undefined &&
typeof field.setValue === 'function'
) {
field.setValue(values[name]);
}
});
return this;
},
/**
* Updates component with new props
* @param {Object} newProps - New properties
* @returns {Object} Form component for chaining
*/
update(newProps) {
// Apply prop standardization to new props
const normalizedNewProps = migrateLegacyProps(newProps);
// Update state
Object.assign(state, normalizedNewProps);
// Update component
component.update(state);
return this;
},
/**
* Clean up resources
*/
destroy() {
// Clean up event listeners
formElement.removeEventListener('submit', handleSubmit);
formElement.removeEventListener('change', handleChange);
formElement.removeEventListener('input', handleInput);
// Clear field references
fields.length = 0;
// Call base component's destroy
component.destroy();
},
};
};
// Define required props for validation
createForm.requiredProps = ['children'];
// Export as a component factory
export default createComponent('Form', createForm);