svarog-ui-core
Version:
Core components of Svarog UI without themes
400 lines (345 loc) • 12 kB
JavaScript
// src/components/Radio/Radio.js
import { createComponent } from '../../utils/componentFactory.js';
import { createBaseComponent } from '../../utils/baseComponent.js';
import {
validateInput,
validateRequiredProps,
} from '../../utils/validation.js';
import { debounce, PerformanceBenchmark } from '../../utils/performance.js';
import { isTestEnvironment } from '../../utils/environment.js';
// CSS injection imports
import { createStyleInjector } from '../../utils/styleInjection.js';
import { radioStyles } from './Radio.styles.js';
// Create style injector for Radio component
const injectRadioStyles = createStyleInjector('Radio');
/**
* Migrate legacy props to standardized props
* @param {Object} props - Component properties
* @returns {Object} Normalized properties
*/
const migrateLegacyProps = (props) => {
const migrated = { ...props };
// No specific property migrations needed for Radio component currently
return migrated;
};
/**
* Create the DOM structure for the radio component
* @param {Object} props - Component properties
* @returns {HTMLElement} The created DOM structure
*/
const createRadioDOM = (props) => {
// Inject styles on first render
injectRadioStyles(radioStyles);
// Create container
const container = document.createElement('div');
container.className = `radio-container ${props.className || ''}`.trim();
// Create label wrapper
const wrapper = document.createElement('label');
wrapper.className = 'radio-wrapper';
// Create input element
const id = props.id || `radio-${Math.random().toString(36).substr(2, 9)}`;
const input = createRadioInput(props, id);
// Create visual indicator
const indicator = document.createElement('span');
indicator.className = 'radio-indicator';
indicator.setAttribute('aria-hidden', 'true');
// Create label text
const labelText = document.createElement('span');
labelText.className = 'radio-label';
labelText.textContent = props.label;
labelText.setAttribute('for', id);
// Build component structure
wrapper.appendChild(input);
wrapper.appendChild(indicator);
wrapper.appendChild(labelText);
container.appendChild(wrapper);
// Add validation container if needed
if (props.validationMessage) {
const validationContainer = createValidationContainer();
container.appendChild(validationContainer);
container.validationContainer = validationContainer;
}
// Store references for updates
container._input = input;
container._label = labelText;
return container;
};
/**
* Create a radio input element with appropriate attributes
* @param {Object} props - Radio properties
* @param {string} id - Element ID
* @returns {HTMLInputElement} The created input element
*/
const createRadioInput = (props, id) => {
const input = document.createElement('input');
input.type = 'radio';
input.className = 'radio-input';
input.id = id;
// Set attributes based on props
if (props.name) input.name = props.name;
if (props.value !== undefined) input.value = props.value;
if (props.required) input.required = true;
input.checked = props.checked || false;
if (props.disabled) input.disabled = true;
// Set ARIA attributes
updateInputAccessibility(input, props);
return input;
};
/**
* Create a validation message container
* @returns {HTMLDivElement} Validation container element
*/
const createValidationContainer = () => {
const container = document.createElement('div');
container.className = 'radio-validation-message';
container.setAttribute('aria-live', 'polite');
return container;
};
/**
* Update input element accessibility attributes
* @param {HTMLInputElement} input - The input element
* @param {Object} props - Component properties
*/
const updateInputAccessibility = (input, props) => {
input.setAttribute('aria-checked', input.checked ? 'true' : 'false');
if (props.disabled) input.setAttribute('aria-disabled', 'true');
if (props.required) input.setAttribute('aria-required', 'true');
};
/**
* Create a change event handler for the radio input
* @param {HTMLInputElement} input - The input element
* @param {HTMLElement} container - The container element
* @param {Object} props - Component properties
* @returns {Function} The event handler
*/
const createChangeHandler = (input, container, props) => {
const handleChange = (event) => {
// Update accessibility state
input.setAttribute('aria-checked', event.target.checked ? 'true' : 'false');
// Validate if required
if (props.validationMessage && container.validationContainer) {
validateInput(input, {
container,
messageElement: container.validationContainer,
customMessage: props.validationMessage,
});
}
// Call onChange callback if provided
if (typeof props.onChange === 'function') {
props.onChange(event, props.value);
}
};
// Use appropriate handler based on environment
return isTestEnvironment() ? handleChange : debounce(handleChange, 50);
};
/**
* Creates a radio button component
* @param {Object} props - Radio properties
* @param {string} props.label - Radio label
* @param {string} props.value - Radio value
* @param {string} [props.id] - Radio id
* @param {string} [props.name] - Radio name
* @param {boolean} [props.checked=false] - Whether the radio is checked
* @param {boolean} [props.required=false] - Whether the radio is required
* @param {boolean} [props.disabled=false] - Whether the radio is disabled
* @param {string} [props.className=''] - Additional CSS class names
* @param {Function} [props.onChange] - Change event handler
* @returns {Object} Radio component API
*/
const createRadio = createBaseComponent((props) => {
// Normalize props
const normalizedProps = migrateLegacyProps(props);
// Validate required props
validateRequiredProps(
normalizedProps,
{
label: { required: true, type: 'string' },
value: { required: true },
id: { required: false, type: 'string' },
name: { required: false, type: 'string' },
checked: { required: false, type: 'boolean' },
required: { required: false, type: 'boolean' },
disabled: { required: false, type: 'boolean' },
className: { required: false, type: 'string' },
onChange: { required: false, type: 'function' },
validationMessage: { required: false, type: 'string' },
},
'Radio'
);
// Create component DOM structure
const container = createRadioDOM(normalizedProps);
const inputElement = container._input;
// Set up event handling
const handler = createChangeHandler(inputElement, container, normalizedProps);
inputElement.addEventListener('change', handler);
inputElement._changeHandler = handler; // Store for cleanup
return container;
});
/**
* Radio component factory with extended API
* @param {Object} props - Radio properties
* @returns {Object} Radio component API
*/
const RadioFactory = (props) => {
// Initialize performance benchmarking
const benchmark = new PerformanceBenchmark('Radio');
// Normalize props
const normalizedProps = migrateLegacyProps(props);
// Create base component
const component = createRadio(normalizedProps);
const element = component.getElement();
const inputElement = element._input;
// Determine when a full rerender is needed
component.shouldRerender = (newProps) => {
const normalizedNewProps = migrateLegacyProps(newProps);
return (
normalizedNewProps.label !== normalizedProps.label ||
normalizedNewProps.className !== normalizedProps.className ||
(normalizedNewProps.validationMessage !==
normalizedProps.validationMessage &&
(!normalizedNewProps.validationMessage ||
!normalizedProps.validationMessage))
);
};
// Partial update implementation
component.partialUpdate = (element, newProps) => {
const endBenchmark = benchmark.start('updates');
// Normalize props
const normalizedNewProps = migrateLegacyProps(newProps);
// Update checked state
if (
normalizedNewProps.checked !== undefined &&
inputElement.checked !== normalizedNewProps.checked
) {
inputElement.checked = normalizedNewProps.checked;
inputElement.setAttribute(
'aria-checked',
normalizedNewProps.checked ? 'true' : 'false'
);
}
// Update disabled state
if (
normalizedNewProps.disabled !== undefined &&
inputElement.disabled !== normalizedNewProps.disabled
) {
inputElement.disabled = normalizedNewProps.disabled;
inputElement.setAttribute(
'aria-disabled',
normalizedNewProps.disabled ? 'true' : 'false'
);
}
// Update required state
if (
normalizedNewProps.required !== undefined &&
inputElement.required !== normalizedNewProps.required
) {
inputElement.required = normalizedNewProps.required;
inputElement.setAttribute(
'aria-required',
normalizedNewProps.required ? 'true' : 'false'
);
}
// Update name attribute
if (
normalizedNewProps.name !== undefined &&
inputElement.name !== normalizedNewProps.name
) {
inputElement.name = normalizedNewProps.name;
}
// Update value
if (
normalizedNewProps.value !== undefined &&
inputElement.value !== normalizedNewProps.value
) {
inputElement.value = normalizedNewProps.value;
}
// Update label text
if (
normalizedNewProps.label !== undefined &&
element._label.textContent !== normalizedNewProps.label
) {
element._label.textContent = normalizedNewProps.label;
}
// Update validation message
if (
normalizedNewProps.validationMessage !== undefined &&
element.validationContainer &&
normalizedProps.validationMessage !== normalizedNewProps.validationMessage
) {
validateInput(inputElement, {
container: element,
messageElement: element.validationContainer,
customMessage: normalizedNewProps.validationMessage,
});
}
// Update props reference
Object.assign(normalizedProps, normalizedNewProps);
endBenchmark();
};
// Enhanced component API
return {
...component,
/**
* Gets the radio value
* @returns {string} The radio value
*/
getValue: () => normalizedProps.value,
/**
* Checks if the radio is checked
* @returns {boolean} Whether the radio is checked
*/
isChecked: () => inputElement.checked,
/**
* Sets the checked state
* @param {boolean} checked - The new checked state
* @returns {Object} Component instance for chaining
*/
setChecked(checked) {
inputElement.checked = checked;
inputElement.setAttribute('aria-checked', checked ? 'true' : 'false');
return this;
},
/**
* Validates the radio input
* @returns {boolean} Whether the input is valid
*/
validate() {
if (element.validationContainer) {
return validateInput(inputElement, {
container: element,
messageElement: element.validationContainer,
customMessage: normalizedProps.validationMessage,
});
}
return inputElement.checkValidity();
},
/**
* Focuses the radio input
* @returns {Object} Component instance for chaining
*/
focus() {
inputElement.focus();
return this;
},
/**
* Get performance metrics
* @returns {Object} Performance metrics
*/
getPerformanceMetrics() {
return benchmark.getSummary();
},
/**
* Custom destroy method to ensure event listener cleanup
*/
destroy() {
if (inputElement && inputElement._changeHandler) {
inputElement.removeEventListener('change', inputElement._changeHandler);
}
component.destroy();
},
};
};
/**
* Radio component factory
*/
export default createComponent('Radio', RadioFactory);