UNPKG

svarog-ui-core

Version:

Core components of Svarog UI without themes

489 lines (418 loc) 14.8 kB
// src/components/Radio/RadioGroup.js import { createComponent } from '../../utils/componentFactory.js'; import { createBaseComponent } from '../../utils/baseComponent.js'; import { withEventDelegation } from '../../utils/composition.js'; import { throttle, PerformanceBenchmark } from '../../utils/performance.js'; import { validateRequiredProps } from '../../utils/validation.js'; import { isTestEnvironment } from '../../utils/environment.js'; import Radio from './Radio.js'; // CSS injection imports import { createStyleInjector } from '../../utils/styleInjection.js'; import { radioGroupStyles } from './RadioGroup.styles.js'; // Create style injector for RadioGroup component const injectRadioGroupStyles = createStyleInjector('RadioGroup'); /** * Migrate legacy props to standardized props * @param {Object} props - Component properties * @returns {Object} Normalized properties */ const migrateLegacyProps = (props) => { const migrated = { ...props }; // Handle defaultActiveTab -> defaultValue alias if ('defaultActiveTab' in props && !('defaultValue' in props)) { console.warn( '[RadioGroup] defaultActiveTab is deprecated, use defaultValue instead' ); migrated.defaultValue = props.defaultActiveTab; delete migrated.defaultActiveTab; } // Use defaultValue to set initial value if not explicitly set if ('defaultValue' in migrated && !('value' in migrated)) { migrated.value = migrated.defaultValue; } return migrated; }; /** * Create the RadioGroup DOM structure * @param {Object} props - Component properties * @returns {HTMLElement} The RadioGroup element */ const createRadioGroupDOM = (props) => { // Inject styles on first render injectRadioGroupStyles(radioGroupStyles); const layout = props.layout || 'vertical'; const className = props.className || ''; // Create fieldset container const container = document.createElement('fieldset'); container.className = `radio-group radio-group--${layout} ${className}`.trim(); container.setAttribute('name', props.name); // Set accessibility attributes container.setAttribute('role', 'radiogroup'); container.setAttribute('aria-labelledby', `legend-${props.name}`); if (props.required) container.setAttribute('aria-required', 'true'); if (props.disabled) container.setAttribute('aria-disabled', 'true'); // Add legend if provided if (props.legend) { const legend = document.createElement('legend'); legend.className = 'radio-group__legend'; legend.textContent = props.legend; legend.id = `legend-${props.name}`; container.appendChild(legend); } // Create options wrapper const optionsWrapper = document.createElement('div'); optionsWrapper.className = `radio-group__options radio-group__options--${layout}`; optionsWrapper.setAttribute('role', 'presentation'); // Add keyboard navigation optionsWrapper.addEventListener('keydown', (event) => { const radios = Array.from( optionsWrapper.querySelectorAll('input[type="radio"]') ); const currentIndex = radios.findIndex( (radio) => radio === document.activeElement ); if (currentIndex === -1) return; let nextIndex; switch (event.key) { case 'ArrowDown': case 'ArrowRight': event.preventDefault(); nextIndex = (currentIndex + 1) % radios.length; focusAndSelectRadio(radios[nextIndex]); break; case 'ArrowUp': case 'ArrowLeft': event.preventDefault(); nextIndex = (currentIndex - 1 + radios.length) % radios.length; focusAndSelectRadio(radios[nextIndex]); break; } }); // Create and add radio options const fragment = document.createDocumentFragment(); const radioComponents = []; const isInTestEnv = isTestEnvironment(); props.options.forEach((option, index) => { // In test environment, directly attach onChange to make testing easier const onChangeHandler = isInTestEnv && props.onChange ? (event, value) => props.onChange(event, value) : null; const radio = Radio({ label: option.label, value: option.value, id: option.id || `${props.name}-${index}`, name: props.name, checked: option.value === props.value, required: props.required && index === 0, // Only set required on first radio disabled: props.disabled || option.disabled, onChange: onChangeHandler, }); fragment.appendChild(radio.getElement()); radioComponents.push(radio); }); optionsWrapper.appendChild(fragment); container.appendChild(optionsWrapper); // Create validation message element if needed if (props.showValidation !== false) { const messageElement = document.createElement('div'); messageElement.className = 'radio-group__validation-message'; messageElement.textContent = ''; messageElement.setAttribute('aria-live', 'polite'); messageElement.id = `validation-${props.name}`; container.appendChild(messageElement); container._messageElement = messageElement; container.setAttribute('aria-describedby', messageElement.id); } // Store references container._radioComponents = radioComponents; container._options = props.options; // Store option data for later reference return container; }; /** * Focus and select a radio button * @param {HTMLInputElement} radio - The radio button element */ const focusAndSelectRadio = (radio) => { radio.focus(); radio.checked = true; radio.dispatchEvent(new Event('change', { bubbles: true })); }; /** * Creates a radio group component * @param {Object} props - RadioGroup properties * @returns {Object} RadioGroup component API */ const createRadioGroup = createBaseComponent((props) => { // Migrate legacy props first const normalizedProps = migrateLegacyProps(props); // Validate required props validateRequiredProps( normalizedProps, { options: { required: true, type: 'array', minLength: 1, validator: (options) => Array.isArray(options) && options.length > 0 ? true : 'array is required and must not be empty', }, name: { required: true, type: 'string' }, value: { required: false }, defaultValue: { required: false }, legend: { required: false, type: 'string' }, required: { required: false, type: 'boolean' }, disabled: { required: false, type: 'boolean' }, className: { required: false, type: 'string' }, onChange: { required: false, type: 'function' }, layout: { required: false, type: 'string', allowedValues: ['vertical', 'horizontal'], validator: (layout) => { if ( layout !== undefined && layout !== 'vertical' && layout !== 'horizontal' ) { return 'must be either "vertical" or "horizontal"'; } return true; }, }, validationMessage: { required: false, type: 'string' }, showValidation: { required: false, type: 'boolean' }, }, 'RadioGroup' ); return createRadioGroupDOM(normalizedProps); }); /** * RadioGroup component factory with extended API * @param {Object} props - RadioGroup properties * @returns {Object} RadioGroup component API */ const RadioGroupFactory = (props) => { // Initialize performance benchmarking const benchmark = new PerformanceBenchmark('RadioGroup'); // Make a copy of props to safely modify const stateProps = { ...migrateLegacyProps(props) }; // Create base component const component = createRadioGroup(stateProps); const element = component.getElement(); // Create the validate method first since it will be called from handleChange const validate = () => { const endBenchmark = benchmark.start('validate'); const isValid = !stateProps.required || stateProps.value !== undefined; if (isValid) { element.classList.remove('radio-group--invalid'); element.classList.add('radio-group--valid'); element.setAttribute('aria-invalid', 'false'); // Clear validation message if (element._messageElement) { element._messageElement.textContent = ''; } } else { element.classList.add('radio-group--invalid'); element.classList.remove('radio-group--valid'); element.setAttribute('aria-invalid', 'true'); // Update validation message if (element._messageElement) { element._messageElement.textContent = stateProps.validationMessage || 'Please select an option'; } } endBenchmark(); return isValid; }; // Handle radio change event - uses module-scoped function to avoid scope issues const handleChange = (event, target) => { const endBenchmark = benchmark.start('events'); // Skip if not a radio button if (target.type !== 'radio') return; // Update internal state stateProps.value = target.value; // Update checked state of all radios const radioElements = element.querySelectorAll('input[type="radio"]'); radioElements.forEach((radio) => { radio.setAttribute('aria-checked', radio.checked ? 'true' : 'false'); }); // Validate if needed if (stateProps.showValidation !== false) { validate(); } // Call onChange callback if provided if (typeof stateProps.onChange === 'function') { stateProps.onChange(event, stateProps.value); } endBenchmark(); }; // Set up event handler that uses throttle for better performance component.handleChange = (event, target) => { if (isTestEnvironment()) { handleChange(event, target); } else { throttle(handleChange, 50)(event, target); } }; // Enhanced component API return { ...component, /** * Gets the selected value * @returns {string} The selected value */ getValue: () => stateProps.value, /** * Sets the selected value * @param {string} value - The new value * @returns {Object} Component instance for chaining */ setValue(value) { const endBenchmark = benchmark.start('setValue'); // Skip if not changed if (stateProps.value === value) return this; stateProps.value = value; // Update checked state of all radios element._radioComponents.forEach((radio) => { radio.setChecked(radio.getValue() === value); }); // Update ARIA attributes const radioElements = element.querySelectorAll('input[type="radio"]'); radioElements.forEach((radio) => { radio.setAttribute('aria-checked', radio.checked ? 'true' : 'false'); }); // Validate if needed if (stateProps.showValidation !== false) { validate(); } endBenchmark(); return this; }, /** * Validates the radio group * @returns {boolean} Whether the radio group is valid */ validate, /** * Focuses the first or checked radio * @returns {Object} Component instance for chaining */ focus() { const radios = element.querySelectorAll('input[type="radio"]'); const checkedRadio = Array.from(radios).find((r) => r.checked) || radios[0]; if (checkedRadio) { checkedRadio.focus(); } return this; }, /** * Get performance metrics * @returns {Object} Performance metrics */ getPerformanceMetrics: () => benchmark.getSummary(), /** * Custom destroy method to clean up radio components */ destroy() { if (element._radioComponents) { element._radioComponents.forEach((radio) => radio.destroy()); } component.destroy(); }, /** * Custom update handler - avoids full rerender when possible */ shouldRerender: (newProps) => { // Migrate legacy props first const normalizedProps = migrateLegacyProps(newProps); return ( normalizedProps.options !== stateProps.options || normalizedProps.layout !== stateProps.layout || normalizedProps.legend !== stateProps.legend || normalizedProps.className !== stateProps.className || (normalizedProps.showValidation !== stateProps.showValidation && normalizedProps.showValidation !== undefined) ); }, /** * Partial update implementation - used when shouldRerender returns false */ partialUpdate(element, newProps) { const endBenchmark = benchmark.start('updates'); // Migrate legacy props first const normalizedProps = migrateLegacyProps(newProps); // Update disabled state if ( normalizedProps.disabled !== undefined && normalizedProps.disabled !== stateProps.disabled ) { element.setAttribute( 'aria-disabled', normalizedProps.disabled ? 'true' : 'false' ); // Update all child radios element._radioComponents.forEach((radio, index) => { const option = stateProps.options[index]; radio.update({ disabled: normalizedProps.disabled || (option && option.disabled) || false, }); }); } // Update required state if ( normalizedProps.required !== undefined && normalizedProps.required !== stateProps.required ) { element.setAttribute( 'aria-required', normalizedProps.required ? 'true' : 'false' ); // Only set required on first radio if (element._radioComponents.length > 0) { element._radioComponents[0].update({ required: normalizedProps.required, }); } } // Update selected value if ( normalizedProps.value !== undefined && normalizedProps.value !== stateProps.value ) { element._radioComponents.forEach((radio) => { radio.setChecked(radio.getValue() === normalizedProps.value); }); } // Update validation message if ( normalizedProps.validationMessage !== undefined && element._messageElement && stateProps.validationMessage !== normalizedProps.validationMessage ) { if (element.classList.contains('radio-group--invalid')) { element._messageElement.textContent = normalizedProps.validationMessage || 'Please select an option'; } } // Update props reference Object.assign(stateProps, normalizedProps); endBenchmark(); }, }; }; /** * RadioGroup component with event delegation */ const EnhancedRadioGroup = withEventDelegation(RadioGroupFactory, { change: 'input[type="radio"]', }); /** * RadioGroup component factory */ export default createComponent('RadioGroup', EnhancedRadioGroup);