svarog-ui-core
Version:
Core components of Svarog UI without themes
489 lines (418 loc) • 14.8 kB
JavaScript
// 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);