Source: ds-form.js

/**
 * @file ds-form.js
 * @summary A custom Web Component that wraps a native `<form>` element with enhanced ARIA support.
 * @description
 * The `ds-form` component provides a styled and functional form element with enhanced
 * accessibility features, validation support, and error state management. It wraps a
 * native form element to maintain full HTML form semantics while adding design system
 * styling and ARIA compliance features.
 *
 * @element ds-form
 * @extends BaseComponent
 *
 * @slot - Renders form controls and other content within the form.
 *
 * @example
 * <!-- Basic form -->
 * <ds-form action="/api/login" method="post">
 *   <ds-fieldset>
 *     <ds-legend>Login Information</ds-legend>
 *     <ds-label for="username">Username</ds-label>
 *     <ds-text-input id="username" name="username" required></ds-text-input>
 *     <ds-label for="password">Password</ds-label>
 *     <ds-text-input id="password" name="password" type="password" required></ds-text-input>
 *   </ds-fieldset>
 *   <ds-button type="submit">Login</ds-button>
 * </ds-form>
 *
 * @example
 * <!-- Form with ARIA attributes -->
 * <ds-form 
 *   action="/api/register" 
 *   method="post" 
 *   aria-label="User registration form"
 *   aria-describedby="form-instructions">
 *   <div id="form-instructions">Please fill out all required fields marked with an asterisk (*)</div>
 *   <ds-fieldset>
 *     <ds-legend>Personal Information</ds-legend>
 *     <ds-label for="firstName">First Name *</ds-label>
 *     <ds-text-input id="firstName" name="firstName" required></ds-text-input>
 *   </ds-fieldset>
 *   <ds-button type="submit">Register</ds-button>
 * </ds-form>
 *
 * @example
 * <!-- Form with custom validation -->
 * <ds-form 
 *   action="/api/contact" 
 *   method="post"
 *   novalidate
 *   data-validation="custom">
 *   <ds-fieldset>
 *     <ds-legend>Contact Information</ds-legend>
 *     <ds-label for="email">Email Address</ds-label>
 *     <ds-text-input id="email" name="email" type="email" required></ds-text-input>
 *   </ds-fieldset>
 *   <ds-button type="submit">Send Message</ds-button>
 * </ds-form>
 */
import BaseComponent from './base-component.js';

class DsForm extends BaseComponent {
    constructor() {
        // ARIA config for ds-form
        const ariaConfig = {
            staticAriaAttributes: {
                'role': 'form'
            },
            dynamicAriaAttributes: [
                'aria-label',
                'aria-describedby',
                'aria-labelledby'
            ],
            requiredAriaAttributes: [],
            referenceAttributes: ['aria-describedby', 'aria-labelledby']
        };
        
        const template = document.createElement('template');
        template.innerHTML = `
            <style>
                @import url('/src/styles/styles.css');
                
                :host {
                    display: block;
                }
                
                .form-wrapper {
                    width: 100%;
                }
                
                form[part="form"] {
                    width: 100%;
                }
                
                .live-region[part="live-region"] {
                    position: absolute;
                    left: -10000px;
                    width: 1px;
                    height: 1px;
                    overflow: hidden;
                }
                
                .live-region[part="live-region"]:not([hidden]) {
                    position: static;
                    width: auto;
                    height: auto;
                    margin-top: var(--ds-spacing-sm);
                    padding: var(--ds-spacing-sm);
                    border-radius: var(--ds-form-border-radius);
                    font-size: 0.9em;
                }
                
                .live-region[part="live-region"][data-type="error"] {
                    background-color: var(--ds-form-error-background);
                    color: var(--ds-form-error-color);
                    border: 1px solid var(--ds-form-error-border);
                }
                
                .live-region[part="live-region"][data-type="success"] {
                    background-color: var(--ds-form-success-background, #d4edda);
                    color: var(--ds-form-success-color, #155724);
                    border: 1px solid var(--ds-form-success-border, #c3e6cb);
                }
                
                .live-region[part="live-region"][data-type="info"] {
                    background-color: var(--ds-form-info-background, #d1ecf1);
                    color: var(--ds-form-info-color, #0c5460);
                    border: 1px solid var(--ds-form-info-border, #bee5eb);
                }
            </style>
            <div class="form-wrapper">
                <form part="form" novalidate>
                    <slot></slot>
                </form>
                <div 
                    part="live-region" 
                    class="live-region"
                    aria-live="polite" 
                    aria-atomic="true" 
                    hidden>
                </div>
            </div>
        `;
        
        super({
            template: template.innerHTML,
            targetSelector: 'form',
            ariaConfig,
            events: ['submit', 'reset', 'input', 'change', 'invalid']
        });
        
        this.form = this.shadowRoot.querySelector('form');
        this.liveRegion = this.shadowRoot.querySelector('[part="live-region"]');
        
        // Form state tracking
        this.formState = {
            submitted: false,
            valid: true,
            errors: new Map(),
            hasValidationErrors: false
        };
        
        // Setup form event handlers
        this.setupFormHandlers();
    }
    
    static get observedAttributes() {
        return [
            'action',
            'method', 
            'enctype',
            'target',
            'novalidate',
            'autocomplete',
            'aria-label',
            'aria-describedby',
            'aria-labelledby'
        ];
    }
    
    /**
     * Sets up form event handlers for validation and accessibility
     */
    setupFormHandlers() {
        // Handle form submission
        this.form.addEventListener('submit', (event) => {
            this.handleFormSubmit(event);
        });
        
        // Handle form reset
        this.form.addEventListener('reset', (event) => {
            this.handleFormReset(event);
        });
        
        // Handle input changes for real-time validation feedback
        this.form.addEventListener('input', (event) => {
            this.handleInputChange(event);
        });
        
        // Handle invalid events for custom validation
        this.form.addEventListener('invalid', (event) => {
            this.handleInvalidEvent(event);
        });
        
        // Handle change events for form state tracking
        this.form.addEventListener('change', (event) => {
            this.handleFormChange(event);
        });
    }
    
    /**
     * Handles form submission with validation and accessibility support
     * @param {Event} event - The submit event
     */
    handleFormSubmit(event) {
        this.formState.submitted = true;
        
        // Check if form is valid using our custom validation
        const formControls = this.querySelectorAll('input, select, textarea, ds-text-input, ds-select, ds-textarea, ds-checkbox, ds-radio');
        let hasErrors = false;
        
        formControls.forEach(control => {
            if (control.hasAttribute('required')) {
                const value = control.value || '';
                if (!value.trim()) {
                    hasErrors = true;
                    this.validateInput(control);
                }
            }
        });
        
        if (hasErrors) {
            event.preventDefault();
            this.handleValidationErrors();
            return;
        }
        
        // Form is valid, allow submission
        this.clearLiveRegion();
        this.announceToScreenReader('Form submitted successfully');
    }
    
    /**
     * Handles form reset
     * @param {Event} event - The reset event
     */
    handleFormReset(event) {
        this.formState = {
            submitted: false,
            valid: true,
            errors: new Map(),
            hasValidationErrors: false
        };
        
        this.clearLiveRegion();
        this.announceToScreenReader('Form has been reset');
    }
    
    /**
     * Handles input changes for real-time validation
     * @param {Event} event - The input event
     */
    handleInputChange(event) {
        const input = event.target;
        
        // Clear previous error for this input
        if (this.formState.errors.has(input)) {
            this.formState.errors.delete(input);
            this.updateLiveRegion();
        }
        
        // If form was previously submitted, validate on input change
        if (this.formState.submitted) {
            this.validateInput(input);
        }
    }
    
    /**
     * Handles invalid events for custom validation
     * @param {Event} event - The invalid event
     */
    handleInvalidEvent(event) {
        event.preventDefault();
        this.validateInput(event.target);
    }
    
    /**
     * Handles form change events
     * @param {Event} event - The change event
     */
    handleFormChange(event) {
        // Track form state changes
        this.updateFormValidity();
    }
    
    /**
     * Validates a single input element
     * @param {HTMLElement} input - The input element to validate
     */
    validateInput(input) {
        let isValid = true;
        let errorMessage = '';
        
        // Handle different types of inputs
        if (input.checkValidity) {
            // Native form elements
            isValid = input.checkValidity();
            errorMessage = input.validationMessage || 'This field is invalid';
        } else if (input.tagName && input.tagName.toLowerCase().includes('ds-')) {
            // Design system components
            const required = input.hasAttribute('required');
            const value = input.value || '';
            
            if (required && !value.trim()) {
                isValid = false;
                errorMessage = 'This field is required';
            } else if (input.type === 'email' && value) {
                const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                if (!emailRegex.test(value)) {
                    isValid = false;
                    errorMessage = 'Please enter a valid email address';
                }
            }
        }
        
        if (!isValid) {
            this.formState.errors.set(input, errorMessage);
            this.formState.hasValidationErrors = true;
        } else {
            this.formState.errors.delete(input);
        }
        
        this.updateFormValidity();
        this.updateLiveRegion();
    }
    
    /**
     * Updates the overall form validity state
     */
    updateFormValidity() {
        this.formState.valid = this.form.checkValidity();
        
        // Update ARIA attributes based on form state
        if (this.formState.hasValidationErrors) {
            this.form.setAttribute('aria-invalid', 'true');
        } else {
            this.form.removeAttribute('aria-invalid');
        }
    }
    
    /**
     * Handles validation errors and announces them to screen readers
     */
    handleValidationErrors() {
        this.formState.hasValidationErrors = true;
        this.formState.valid = false;
        
        // Collect all error messages
        const errorMessages = Array.from(this.formState.errors.values());
        
        if (errorMessages.length > 0) {
            const errorText = `Form has ${errorMessages.length} validation error${errorMessages.length > 1 ? 's' : ''}: ${errorMessages.join('. ')}`;
            this.announceToScreenReader(errorText, 'error');
        }
        
        this.updateLiveRegion();
    }
    
    /**
     * Updates the live region with current form state
     */
    updateLiveRegion() {
        if (this.formState.errors.size === 0) {
            this.clearLiveRegion();
            return;
        }
        
        const errorMessages = Array.from(this.formState.errors.values());
        const errorText = errorMessages.join('. ');
        
        this.liveRegion.textContent = errorText;
        this.liveRegion.setAttribute('data-type', 'error');
        this.liveRegion.hidden = false;
    }
    
    /**
     * Clears the live region
     */
    clearLiveRegion() {
        this.liveRegion.textContent = '';
        this.liveRegion.hidden = true;
        this.liveRegion.removeAttribute('data-type');
    }
    
    /**
     * Announces a message to screen readers
     * @param {string} message - The message to announce
     * @param {string} type - The type of message (error, success, info)
     */
    announceToScreenReader(message, type = 'info') {
        this.liveRegion.textContent = message;
        this.liveRegion.setAttribute('data-type', type);
        this.liveRegion.hidden = false;
        
        // Hide the message after a delay
        setTimeout(() => {
            this.clearLiveRegion();
        }, 5000);
    }
    
    // Form attribute accessors
    get action() { return this.form.action; }
    set action(val) { this.form.action = val; }
    
    get method() { return this.form.method; }
    set method(val) { this.form.method = val; }
    
    get enctype() { return this.form.enctype; }
    set enctype(val) { this.form.enctype = val; }
    
    get target() { return this.form.target; }
    set target(val) { this.form.target = val; }
    
    get novalidate() { return this.form.hasAttribute('novalidate'); }
    set novalidate(val) { 
        if (val) {
            this.form.setAttribute('novalidate', '');
        } else {
            this.form.removeAttribute('novalidate');
        }
    }
    
    get autocomplete() { return this.form.autocomplete; }
    set autocomplete(val) { this.form.autocomplete = val; }
    
    // ARIA property accessors
    get ariaLabel() { 
        const value = this.form.getAttribute('aria-label');
        return value === null ? null : value;
    }
    set ariaLabel(val) { 
        if (val === null || val === undefined) {
            this.form.removeAttribute('aria-label');
        } else {
            this.form.setAttribute('aria-label', val);
        }
    }
    
    get ariaDescribedBy() { 
        const value = this.form.getAttribute('aria-describedby');
        return value === null ? null : value;
    }
    set ariaDescribedBy(val) { 
        if (val === null || val === undefined) {
            this.form.removeAttribute('aria-describedby');
        } else {
            this.form.setAttribute('aria-describedby', val);
        }
    }
    
    get ariaLabelledBy() { 
        const value = this.form.getAttribute('aria-labelledby');
        return value === null ? null : value;
    }
    set ariaLabelledBy(val) { 
        if (val === null || val === undefined) {
            this.form.removeAttribute('aria-labelledby');
        } else {
            this.form.setAttribute('aria-labelledby', val);
        }
    }
    
    /**
     * Submits the form programmatically
     */
    submit() {
        this.form.submit();
    }
    
    /**
     * Resets the form programmatically
     */
    reset() {
        this.form.reset();
        this.handleFormReset(new Event('reset'));
    }
    
    /**
     * Checks if the form is valid
     * @returns {boolean} True if the form is valid
     */
    checkValidity() {
        return this.form.checkValidity();
    }
    
    /**
     * Reports validity of the form
     * @returns {boolean} True if the form is valid
     */
    reportValidity() {
        return this.form.reportValidity();
    }
    
    /**
     * Gets form data as FormData object
     * @returns {FormData} The form data
     */
    getFormData() {
        const formData = new FormData();
        
        // Get all form controls (native and custom)
        const formControls = this.querySelectorAll('input, select, textarea, ds-text-input, ds-select, ds-textarea, ds-checkbox, ds-radio');
        
        formControls.forEach(control => {
            const name = control.name || control.getAttribute('name');
            if (!name) return;
            
            let value = '';
            let isCheckbox = false;
            let isRadio = false;
            let isChecked = false;
            const tag = control.tagName.toLowerCase();
            
            // Native and custom checkboxes/radios
            if (control.type === 'checkbox' || tag === 'ds-checkbox') {
                isCheckbox = true;
                isChecked = control.checked === true || control.hasAttribute('checked');
            } else if (control.type === 'radio' || tag === 'ds-radio') {
                isRadio = true;
                isChecked = control.checked === true || control.hasAttribute('checked');
            }
            
            if (isCheckbox || isRadio) {
                if (isChecked) {
                    // For custom elements, prefer getAttribute('value')
                    if (tag.startsWith('ds-')) {
                        value = control.getAttribute('value') ?? control.value ?? 'on';
                    } else {
                        value = control.value || 'on';
                    }
                    formData.append(name, value);
                }
                // If not checked, do not include in form data
            } else {
                // For text inputs, selects, textareas, and other custom components
                value = control.value || '';
                formData.append(name, value);
            }
        });
        
        return formData;
    }
    
    /**
     * Gets form data as a plain object
     * @returns {Object} The form data as key-value pairs
     */
    getFormDataAsObject() {
        const formData = this.getFormData();
        const data = {};
        
        for (const [key, value] of formData.entries()) {
            data[key] = value;
        }
        
        return data;
    }
    
    // Override validateARIA for form-specific checks
    validateARIA() {
        const errors = super.validateARIA ? super.validateARIA() : [];
        
        // Check for accessible name (aria-label, aria-labelledby, or form title)
        const ariaLabel = this.form.getAttribute('aria-label');
        const ariaLabelledBy = this.form.getAttribute('aria-labelledby');
        const formTitle = this.querySelector('h1, h2, h3, h4, h5, h6');
        
        if (!ariaLabel && !ariaLabelledBy && !formTitle) {
            errors.push('Form should have an accessible name');
        }
        
        // Check for proper form structure
        const hasFormControls = this.querySelector('input, select, textarea, ds-text-input, ds-select, ds-textarea, ds-checkbox, ds-radio, button[type="submit"]');
        if (!hasFormControls) {
            errors.push('Form should contain form controls');
        }
        
        return errors;
    }
}

// Register the custom element
if (!customElements.get('ds-form')) {
  customElements.define('ds-form', DsForm);
}

export default DsForm;