svarog-ui-core
Version:
Core components of Svarog UI without themes
540 lines (454 loc) • 15.3 kB
JavaScript
// src/components/Textarea/Textarea.js
import { createElement, appendChildren } from '../../utils/componentFactory.js';
import { withThemeAwareness } from '../../utils/composition.js';
import { createBaseComponent } from '../../utils/baseComponent.js';
import { createStyleInjector } from '../../utils/styleInjection.js';
import { textareaStyles } from './Textarea.styles.js';
// Create style injector for Textarea component
const injectTextareaStyles = createStyleInjector('Textarea');
/**
* Create a Textarea component
* @param {Object} props - Textarea properties
* @returns {Object} Textarea component
*/
const createTextarea = (props) => {
// Inject styles on component creation
injectTextareaStyles(textareaStyles);
// Migrate legacy props to standardized props
const normalizedProps = migrateLegacyProps(props);
// Use base component for standardized lifecycle
const baseComponent = createBaseComponent((state) => {
const {
id,
name,
value,
defaultValue,
placeholder = '',
required = false,
disabled = false,
readonly = false,
rows = 3,
cols,
minLength,
maxLength,
className = '',
error = null,
errorMessage = '',
loading = false,
showValidation = true,
autoResize = true,
resizable = true,
showCharCount = false,
} = state;
// Use value if provided, otherwise use defaultValue
const currentValue = value !== undefined ? value : defaultValue || '';
// Create container with data attribute for delegation
const container = createElement('div', {
classes: ['textarea-container', className],
attributes: {
'data-component': 'textarea',
'data-valid': error === false ? 'true' : error ? 'false' : null,
},
});
// Create textarea wrapper
const textareaWrapper = createElement('div', {
classes: ['textarea-wrapper'],
});
// Create custom textarea UI
const customClasses = ['textarea-custom'];
if (disabled) customClasses.push('textarea-custom--disabled');
if (readonly) customClasses.push('textarea-custom--readonly');
if (loading) customClasses.push('textarea-custom--loading');
if (error === false) customClasses.push('textarea-custom--valid');
if (error) customClasses.push('textarea-custom--invalid');
if (!resizable) customClasses.push('textarea-custom--no-resize');
if (autoResize) customClasses.push('textarea-custom--auto-resize');
const customTextarea = createElement('div', {
classes: customClasses,
attributes: {
'data-element': 'custom-textarea',
},
});
// Create native textarea
const textareaAttributes = {
id,
name,
placeholder,
required: required ? 'required' : null,
rows,
'aria-required': required ? 'true' : null,
'aria-invalid': error ? 'true' : null,
'data-element': 'textarea',
};
// Add optional attributes
if (cols !== undefined) textareaAttributes.cols = cols;
if (minLength !== undefined) textareaAttributes.minLength = minLength;
if (maxLength !== undefined) textareaAttributes.maxLength = maxLength;
if (disabled) textareaAttributes.disabled = true;
if (readonly) textareaAttributes.readOnly = true;
const textarea = createElement('textarea', {
classes: ['textarea-native'],
attributes: textareaAttributes,
});
// Set value directly
textarea.value = currentValue;
// Create character count display
let charCountElement = null;
if (showCharCount || maxLength !== undefined) {
const charCount = currentValue.length;
const charCountText =
maxLength !== undefined ? `${charCount}/${maxLength}` : `${charCount}`;
charCountElement = createElement('div', {
classes: ['textarea-char-count'],
attributes: {
'data-element': 'char-count',
'aria-live': 'polite',
},
text: charCountText,
});
}
// Append elements
appendChildren(textareaWrapper, [customTextarea, textarea]);
if (charCountElement) {
textareaWrapper.appendChild(charCountElement);
}
// Create validation message element
const validationMessageElement = createElement('div', {
classes: ['textarea-validation-message'],
attributes: {
'aria-live': 'polite',
'data-element': 'validation-message',
},
text: error ? errorMessage : '',
style: {
display: showValidation ? 'block' : 'none',
},
});
appendChildren(container, [textareaWrapper, validationMessageElement]);
return container;
});
// Initialize component with normalized props
const component = baseComponent(normalizedProps);
const element = component.getElement();
// Store state
let state = { ...normalizedProps };
// Initialize state.value from defaultValue if value is not provided
if (state.value === undefined && state.defaultValue !== undefined) {
state.value = state.defaultValue;
}
/**
* Auto-resize textarea based on content
*/
const autoResizeTextarea = () => {
if (!state.autoResize) return;
const textarea = element.querySelector('.textarea-native');
if (!textarea) return;
// Reset height to auto to get accurate scrollHeight
textarea.style.height = 'auto';
// Set height to match content
const computedStyle = window.getComputedStyle(textarea);
const minHeight = parseInt(computedStyle.minHeight) || 0;
const newHeight = Math.max(textarea.scrollHeight, minHeight);
textarea.style.height = `${newHeight}px`;
// Update custom element height
const customTextarea = element.querySelector('.textarea-custom');
if (customTextarea) {
customTextarea.style.height = `${newHeight}px`;
}
};
/**
* Update character count display
*/
const updateCharCount = () => {
const charCountElement = element.querySelector(
'[data-element="char-count"]'
);
if (!charCountElement) return;
const currentLength = (state.value || '').length;
const charCountText =
state.maxLength !== undefined
? `${currentLength}/${state.maxLength}`
: `${currentLength}`;
charCountElement.textContent = charCountText;
// Add warning class if approaching limit
if (state.maxLength !== undefined) {
const ratio = currentLength / state.maxLength;
charCountElement.classList.toggle(
'textarea-char-count--warning',
ratio >= 0.9
);
charCountElement.classList.toggle(
'textarea-char-count--error',
ratio >= 1
);
}
};
/**
* Handle textarea focus event
* @param {Event} event - The focus event
*/
const handleFocus = (event) => {
element.classList.add('textarea-container--focused');
const customTextarea = element.querySelector('.textarea-custom');
if (customTextarea) {
customTextarea.classList.add('textarea-custom--focused');
}
if (typeof state.onFocus === 'function') {
state.onFocus(event);
}
};
/**
* Handle textarea blur event
* @param {Event} event - The blur event
*/
const handleBlur = (event) => {
element.classList.remove('textarea-container--focused');
const customTextarea = element.querySelector('.textarea-custom');
if (customTextarea) {
customTextarea.classList.remove('textarea-custom--focused');
}
if (state.showValidation) {
validate();
}
if (typeof state.onBlur === 'function') {
state.onBlur(event);
}
};
// Event delegation handler
const handleEvents = (event) => {
const targetElement = event.target;
const eventType = event.type;
// Handle textarea element events
if (targetElement.classList.contains('textarea-native')) {
switch (eventType) {
case 'input':
// Update state but don't re-render the component
state.value = targetElement.value;
// Update character count
updateCharCount();
// Auto-resize if enabled
if (state.autoResize) {
autoResizeTextarea();
}
// Update validation if needed
if (state.showValidation) {
validate();
}
// Call onChange handler
if (typeof state.onChange === 'function') {
state.onChange(event, getValue());
}
break;
case 'focus':
handleFocus(event);
break;
case 'blur':
handleBlur(event);
break;
case 'keydown':
handleKeydown(event);
break;
}
}
};
/**
* Handle keydown events
* @param {KeyboardEvent} event - Keyboard event
*/
const handleKeydown = (event) => {
// Handle Tab key for accessibility
if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey) {
// Allow default tab behavior
return;
}
// Handle Ctrl/Cmd + Enter for form submission
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
if (typeof state.onSubmit === 'function') {
state.onSubmit(event, getValue());
}
}
};
/**
* Get the textarea value
* @returns {string} Current value
*/
const getValue = () => {
return state.value || '';
};
/**
* Set the textarea value
* @param {string} newValue - New value
* @returns {Object} Component for chaining
*/
const setValue = (newValue) => {
state.value = newValue;
const textareaElement = element.querySelector('.textarea-native');
if (textareaElement) {
textareaElement.value = newValue;
}
updateCharCount();
if (state.autoResize) {
autoResizeTextarea();
}
if (state.showValidation) {
validate();
}
return api;
};
/**
* Validate the textarea
* @returns {boolean} Whether the textarea is valid
*/
const validate = () => {
const textareaElement = element.querySelector('.textarea-native');
const customElement = element.querySelector('.textarea-custom');
const messageElement = element.querySelector(
'.textarea-validation-message'
);
if (!textareaElement || !customElement || !messageElement) return false;
// Use native validation
const isValid = textareaElement.checkValidity();
// Update state.error
state.error = isValid ? false : true;
// Update UI
element.setAttribute('data-valid', isValid ? 'true' : 'false');
customElement.classList.toggle('textarea-custom--valid', isValid);
customElement.classList.toggle('textarea-custom--invalid', !isValid);
// Update validation message
messageElement.textContent = !isValid
? state.errorMessage || textareaElement.validationMessage
: '';
return isValid;
};
// Set up delegated event listeners
element.addEventListener('input', handleEvents);
element.addEventListener('focus', handleEvents, true); // Use capture for focus/blur
element.addEventListener('blur', handleEvents, true);
element.addEventListener('keydown', handleEvents);
// Initial setup
if (state.autoResize) {
// Use RAF to ensure DOM is ready
requestAnimationFrame(() => {
autoResizeTextarea();
});
}
/**
* Migrate legacy props to standardized props
* @param {Object} props - Original props
* @returns {Object} Normalized props
*/
function migrateLegacyProps(props) {
const migrated = { ...props };
// Migrate isValid to error
if ('isValid' in props && !('error' in props)) {
console.warn('[Textarea] isValid is deprecated, use error instead');
if (props.isValid === true) {
migrated.error = false;
} else if (props.isValid === false) {
migrated.error = true;
} else {
migrated.error = null;
}
delete migrated.isValid;
}
// Migrate validationMessage to errorMessage
if ('validationMessage' in props && !('errorMessage' in props)) {
console.warn(
'[Textarea] validationMessage is deprecated, use errorMessage instead'
);
migrated.errorMessage = props.validationMessage;
delete migrated.validationMessage;
}
// Migrate isLoading to loading
if ('isLoading' in props && !('loading' in props)) {
console.warn('[Textarea] isLoading is deprecated, use loading instead');
migrated.loading = props.isLoading;
delete migrated.isLoading;
}
return migrated;
}
// Public API
const api = {
/**
* Get the component element
* @returns {HTMLElement} Component element
*/
getElement() {
return element;
},
/**
* Get the current value
* @returns {string} Current value
*/
getValue,
/**
* Set the textarea value
* @param {string} value - New value
* @returns {Object} Component for chaining
*/
setValue,
/**
* Validate the textarea
* @returns {boolean} Whether textarea is valid
*/
validate,
/**
* Update component with new props
* @param {Object} newProps - New properties
* @returns {Object} Component for chaining
*/
update(newProps) {
// Normalize new props
const normalizedProps = migrateLegacyProps(newProps);
// Update state
state = { ...state, ...normalizedProps };
// For full updates, use the component update
component.update(state);
// Handle special update cases
const textareaElement = element.querySelector('.textarea-native');
const customTextarea = element.querySelector('.textarea-custom');
// Update value if changed
if (normalizedProps.value !== undefined && textareaElement) {
textareaElement.value = normalizedProps.value;
updateCharCount();
if (state.autoResize) {
autoResizeTextarea();
}
}
// Update validation classes directly
if (normalizedProps.error === false && customTextarea) {
customTextarea.classList.add('textarea-custom--valid');
customTextarea.classList.remove('textarea-custom--invalid');
} else if (normalizedProps.error === true && customTextarea) {
customTextarea.classList.add('textarea-custom--invalid');
customTextarea.classList.remove('textarea-custom--valid');
}
return api;
},
/**
* Theme change handler (for withThemeAwareness HOC)
* @param {string} theme - New theme
* @param {string} previousTheme - Previous theme
*/
onThemeChange(theme, previousTheme) {
console.debug(
`Textarea: theme changed from ${previousTheme} to ${theme}`
);
},
/**
* Clean up resources
*/
destroy() {
// Remove all event listeners
element.removeEventListener('input', handleEvents);
element.removeEventListener('focus', handleEvents, true);
element.removeEventListener('blur', handleEvents, true);
element.removeEventListener('keydown', handleEvents);
// Call base component's destroy
component.destroy();
},
};
return api;
};
// Enhance component with theme awareness
export default withThemeAwareness(createTextarea);