svarog-ui-core
Version:
Core components of Svarog UI without themes
409 lines (352 loc) • 11.9 kB
JavaScript
// src/components/ConditionSelector/ConditionSelector.js
import {
createElement,
validateProps,
createComponent,
} from '../../utils/componentFactory.js';
import { createBaseComponent } from '../../utils/baseComponent.js';
import { withThemeAwareness } from '../../utils/composition.js';
// CSS injection imports
import { createStyleInjector } from '../../utils/styleInjection.js';
import { conditionSelectorStyles } from './ConditionSelector.styles.js';
// Create style injector for ConditionSelector component
const injectConditionSelectorStyles = createStyleInjector('ConditionSelector');
/**
* Migrates legacy props to new standardized props
* @param {Object} props - Component props
* @returns {Object} - Normalized props
*/
const migrateLegacyProps = (props) => {
const migrated = { ...props };
// Migrate onSelect to onChange
if ('onSelect' in props && !('onChange' in props)) {
console.warn(
'[ConditionSelector] onSelect is deprecated, use onChange instead'
);
migrated.onChange = props.onSelect;
}
// Migrate isLoading to loading
if ('isLoading' in props && !('loading' in props)) {
console.warn(
'[ConditionSelector] isLoading is deprecated, use loading instead'
);
migrated.loading = props.isLoading;
}
return migrated;
};
/**
* Validates condition-specific props
* @param {Object} props - Condition selector properties
*/
const validateConditionSelectorProps = (props) => {
// Validate conditions array if provided
if (props.conditions) {
if (!Array.isArray(props.conditions)) {
throw new Error('ConditionSelector: conditions must be an array');
}
// Validate condition objects
props.conditions.forEach((condition, index) => {
if (!condition.id) {
throw new Error(
`ConditionSelector: condition at index ${index} must have an id property`
);
}
if (!condition.name) {
throw new Error(
`ConditionSelector: condition at index ${index} must have a name property`
);
}
});
}
// Validate selectedId if provided
if (props.selectedId && props.conditions) {
const idExists = props.conditions.some(
(c) => c.id.toString() === props.selectedId.toString()
);
if (!idExists) {
console.warn(
`ConditionSelector: selectedId "${props.selectedId}" does not match any condition id`
);
}
}
};
/**
* Get an icon for a condition based on its name
* @param {string} conditionName - Name of the condition
* @returns {string} Icon representation
*/
const getConditionIcon = (conditionName) => {
const lowerName = conditionName.toLowerCase();
if (lowerName.includes('new') || lowerName.includes('neu')) {
return '✨';
} else if (lowerName.includes('good') || lowerName.includes('gut')) {
return '👍';
} else if (lowerName.includes('fair') || lowerName.includes('akzeptabel')) {
return '👌';
} else if (lowerName.includes('poor') || lowerName.includes('schlecht')) {
return '🔧';
}
return '📱';
};
/**
* Function to handle condition selection
* @param {Object} state - Component state
* @param {string|number} conditionId - ID of the selected condition
*/
const handleConditionSelect = (state, conditionId) => {
if (!state || state.loading || !state.onChange) {
return;
}
try {
// Convert ID to string for consistent comparison
const id = conditionId.toString();
// Validate the condition exists
const conditionExists = state.conditions.some(
(c) => c.id.toString() === id
);
if (!conditionExists) {
console.warn(`ConditionSelector: No condition found with id "${id}"`);
return;
}
// Call onChange callback
state.onChange(conditionId);
} catch (error) {
console.error(
'ConditionSelector: Error in condition selection handler',
error
);
}
};
/**
* Creates a single condition option
* @param {Object} condition - Condition data
* @param {string} selectedId - Currently selected condition ID
* @param {Function} clickHandler - Click handler function
* @returns {HTMLElement} Condition option element
*/
const createConditionOption = (condition, selectedId, clickHandler) => {
const isSelected = condition.id.toString() === selectedId.toString();
const conditionId = `condition-${condition.id}`;
// Create option container
const optionContainer = createElement('div', {
classes: [
'condition-option',
isSelected ? 'condition-option--selected' : '',
],
attributes: {
'data-condition-id': condition.id.toString(),
role: 'radiogroup',
},
});
// Create radio input
const radioInput = createElement('input', {
attributes: {
type: 'radio',
name: 'condition',
id: conditionId,
value: condition.id.toString(),
checked: isSelected ? '' : null,
'aria-labelledby': `${conditionId}-title`,
'aria-describedby': `${conditionId}-description`,
},
});
// Create label with click handler
const optionLabel = createElement('label', {
classes: 'condition-option__label',
attributes: {
for: conditionId,
},
events: {
click: clickHandler,
},
});
// Store reference to the handler for cleanup
optionLabel._conditionClickHandler = clickHandler;
// Create icon
const icon = createElement('span', {
classes: 'condition-option__icon',
text: getConditionIcon(condition.name),
attributes: {
'aria-hidden': 'true',
},
});
// Create content container
const content = createElement('div', {
classes: 'condition-option__content',
});
// Create title
const title = createElement('div', {
classes: 'condition-option__title',
text: condition.name,
attributes: {
id: `${conditionId}-title`,
},
});
// Create description
const description = createElement('div', {
classes: 'condition-option__description',
text: condition.description || '',
attributes: {
id: `${conditionId}-description`,
},
});
// Assemble the elements
content.appendChild(title);
content.appendChild(description);
optionLabel.appendChild(icon);
optionLabel.appendChild(content);
optionContainer.appendChild(radioInput);
optionContainer.appendChild(optionLabel);
return optionContainer;
};
/**
* Creates the condition selector DOM structure
* @param {Object} state - Component state
* @returns {HTMLElement} Root element for the component
*/
const renderConditionSelector = (state) => {
// Inject styles on first render
injectConditionSelectorStyles(conditionSelectorStyles);
const { conditions, selectedId, loading, className } = state;
// Create container with appropriate classes
const classes = [
'condition-selector',
className,
loading ? 'condition-selector--loading' : '',
].filter(Boolean);
const container = createElement('div', {
classes,
attributes: {
role: 'region',
'aria-busy': loading ? 'true' : 'false',
'aria-label': 'Condition Selection',
},
});
// If there are no conditions, return empty container
if (!conditions || !conditions.length) {
// Add empty state message for screen readers
const emptyMessage = createElement('div', {
classes: 'sr-only',
text: 'No conditions available',
attributes: {
'aria-live': 'polite',
},
});
container.appendChild(emptyMessage);
return container;
}
// Create condition options container
const optionsContainer = createElement('div', {
classes: 'condition-options',
attributes: {
role: 'group',
'aria-label': 'Available conditions',
},
});
// Create condition options
conditions.forEach((condition) => {
// Create a closure for this specific condition's click handler
const clickHandler = () => handleConditionSelect(state, condition.id);
const option = createConditionOption(condition, selectedId, clickHandler);
optionsContainer.appendChild(option);
});
container.appendChild(optionsContainer);
// Store a reference to the state object on the container
container._state = { ...state };
return container;
};
/**
* Create a ConditionSelector component
* @param {Object} props - ConditionSelector properties
* @param {Array<{id: (string|number), name: string, description?: string}>} [props.conditions=[]] - Array of condition objects
* @param {Function} [props.onChange=null] - Callback when a condition is selected
* @param {Function} [props.onSelect=null] - (Deprecated) Callback when a condition is selected
* @param {string|number} [props.selectedId=''] - ID of the currently selected condition
* @param {boolean} [props.loading=false] - Whether the component is in loading state
* @param {boolean} [props.isLoading=false] - (Deprecated) Whether the component is in loading state
* @param {string} [props.className=''] - Additional CSS class names
* @returns {Object} ConditionSelector component
*/
const createConditionSelector = (props) => {
// Migrate legacy props
const normalizedProps = migrateLegacyProps(props);
// Validate props
validateProps(normalizedProps, createConditionSelector.requiredProps);
validateConditionSelectorProps(normalizedProps);
// Initial state with defaults
const initialState = {
conditions: normalizedProps.conditions || [],
onChange: normalizedProps.onChange || null,
selectedId: normalizedProps.selectedId
? normalizedProps.selectedId.toString()
: '',
loading: normalizedProps.loading || false,
className: normalizedProps.className || '',
};
// Create the base component
const conditionSelector = createBaseComponent(renderConditionSelector)(
initialState
);
// Define the shouldRerender method
conditionSelector.shouldRerender = (newProps) => {
// Normalize new props
const normalizedNewProps = migrateLegacyProps(newProps);
// These props require a full re-render
return [
'conditions',
'className',
'loading',
'isLoading',
'selectedId',
].some((prop) => normalizedNewProps[prop] !== undefined);
};
// Add theme change handler
conditionSelector.onThemeChange = (newTheme, previousTheme) => {
console.debug(
`ConditionSelector: theme changed from ${previousTheme} to ${newTheme}`
);
};
// Add convenience methods
conditionSelector.setLoading = function (isLoading) {
return this.update({ loading: isLoading });
};
conditionSelector.updateConditions = function (conditions, selectedId = '') {
return this.update({
conditions,
selectedId,
});
};
conditionSelector.setSelectedCondition = function (conditionId) {
return this.update({ selectedId: conditionId.toString() });
};
// Add custom destroy method to ensure proper cleanup
const originalDestroy = conditionSelector.destroy;
conditionSelector.destroy = function () {
const element = this.getElement();
// Find all labels with click handlers and clean them up
const labels = element.querySelectorAll('.condition-option__label');
labels.forEach((label) => {
if (label._conditionClickHandler) {
label.removeEventListener('click', label._conditionClickHandler);
delete label._conditionClickHandler;
}
});
// Remove circular references
if (element._state) {
delete element._state;
}
// Call original destroy
if (originalDestroy) {
originalDestroy.call(this);
}
};
return conditionSelector;
};
// Define required props (none are strictly required)
createConditionSelector.requiredProps = [];
// Create the component with theme awareness
const ConditionSelector = withThemeAwareness(
createComponent('ConditionSelector', createConditionSelector)
);
// Export as a factory function
export default ConditionSelector;