/**
* Widget Base Utilities
*
* Provides common utilities for widget development:
* - Standard widget HTML structure
* - Editable field handlers
* - Configuration UI helpers
* - Event listener management
*/
/**
* Create standard widget container structure
* @param {Object} options - Widget options
* @param {string} options.title - Widget title
* @param {string} options.icon - Widget icon (emoji or FontAwesome class)
* @param {string} options.content - Widget content HTML
* @param {string} [options.headerClass] - Additional header CSS class
* @param {string} [options.contentClass] - Additional content CSS class
* @returns {string} Widget HTML
*/
export function createWidgetContainer({ title, icon, content, headerClass = '', contentClass = '' }) {
return `
`;
}
/**
* Create editable field with auto-save
* @param {Object} options - Field options
* @param {string} options.value - Field value
* @param {string} options.field - Field name (for data-field attribute)
* @param {string} [options.placeholder] - Placeholder text
* @param {string} [options.className] - Additional CSS class
* @param {Function} [options.onSave] - Callback when field saved
* @returns {string} Editable field HTML
*/
export function createEditableField({ value, field, placeholder = '', className = '', onSave }) {
const dataAttr = onSave ? `data-on-save="true"` : '';
return `
${value}
`;
}
/**
* Attach editable field handlers to a container
* @param {HTMLElement} container - Container element
* @param {Function} onFieldChange - Callback (fieldName, newValue) => void
*/
export function attachEditableHandlers(container, onFieldChange) {
if (!container) return;
// Find all editable fields
const editableFields = container.querySelectorAll('[contenteditable="true"]');
editableFields.forEach(field => {
// Store original value
let originalValue = field.textContent.trim();
// Focus event - select all text
field.addEventListener('focus', (e) => {
originalValue = e.target.textContent.trim();
// Select all text
const range = document.createRange();
range.selectNodeContents(e.target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
// Blur event - save changes
field.addEventListener('blur', (e) => {
const newValue = e.target.textContent.trim();
const fieldName = e.target.dataset.field;
if (newValue !== originalValue && newValue !== '') {
console.log(`[WidgetBase] Field changed: ${fieldName} = ${newValue}`);
if (onFieldChange) {
onFieldChange(fieldName, newValue);
}
} else if (newValue === '') {
// Restore original if empty
e.target.textContent = originalValue;
}
});
// Enter key - blur to save
field.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.target.blur();
}
// Escape key - cancel edit
if (e.key === 'Escape') {
e.preventDefault();
e.target.textContent = originalValue;
e.target.blur();
}
});
// Prevent paste with formatting
field.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
});
}
/**
* Create progress bar HTML
* @param {Object} options - Progress bar options
* @param {string} options.label - Label text
* @param {number} options.value - Current value (0-100)
* @param {string} [options.gradient] - CSS gradient for bar
* @param {boolean} [options.editable] - Whether value is editable
* @param {string} [options.field] - Field name for editable value
* @returns {string} Progress bar HTML
*/
export function createProgressBar({ label, value, gradient, editable = false, field = '' }) {
const barStyle = gradient ? `background: ${gradient}` : '';
const valueHtml = editable
? `${value}%`
: `${value}%`;
return `
`;
}
/**
* Update progress bar value
* @param {HTMLElement} container - Container element
* @param {string} field - Field name
* @param {number} newValue - New value (0-100)
*/
export function updateProgressBar(container, field, newValue) {
const valueSpan = container.querySelector(`[data-field="${field}"]`);
const fillDiv = valueSpan?.parentElement.querySelector('.rpg-stat-fill');
if (valueSpan) {
valueSpan.textContent = `${newValue}%`;
}
if (fillDiv) {
fillDiv.style.width = `${100 - newValue}%`;
}
}
/**
* Create icon button
* @param {Object} options - Button options
* @param {string} options.icon - FontAwesome icon class or emoji
* @param {string} [options.label] - Button label
* @param {string} [options.className] - Additional CSS class
* @param {string} [options.title] - Tooltip text
* @returns {string} Button HTML
*/
export function createIconButton({ icon, label = '', className = '', title = '' }) {
const isFontAwesome = icon.startsWith('fa-');
const iconHtml = isFontAwesome
? ``
: `${icon}`;
return `
`;
}
/**
* Create toggle switch
* @param {Object} options - Toggle options
* @param {string} options.id - Toggle ID
* @param {string} options.label - Toggle label
* @param {boolean} options.checked - Initial checked state
* @param {Function} [options.onChange] - Change callback
* @returns {string} Toggle HTML
*/
export function createToggle({ id, label, checked = false, onChange }) {
return `
`;
}
/**
* Attach toggle handler
* @param {HTMLElement} container - Container element
* @param {string} toggleId - Toggle input ID
* @param {Function} onChange - Callback (checked) => void
*/
export function attachToggleHandler(container, toggleId, onChange) {
const toggle = container.querySelector(`#${toggleId}`);
if (!toggle) return;
toggle.addEventListener('change', (e) => {
if (onChange) {
onChange(e.target.checked);
}
});
}
/**
* Create select dropdown
* @param {Object} options - Select options
* @param {string} options.id - Select ID
* @param {Array<{value: string, label: string}>} options.options - Options array
* @param {string} [options.selected] - Selected value
* @param {string} [options.className] - Additional CSS class
* @returns {string} Select HTML
*/
export function createSelect({ id, options, selected = '', className = '' }) {
const optionsHtml = options.map(opt =>
``
).join('');
return `
`;
}
/**
* Attach select handler
* @param {HTMLElement} container - Container element
* @param {string} selectId - Select element ID
* @param {Function} onChange - Callback (value) => void
*/
export function attachSelectHandler(container, selectId, onChange) {
const select = container.querySelector(`#${selectId}`);
if (!select) return;
select.addEventListener('change', (e) => {
if (onChange) {
onChange(e.target.value);
}
});
}
/**
* Create configuration section
* @param {Object} options - Config options
* @param {string} options.title - Section title
* @param {string} options.content - Section content HTML
* @param {boolean} [options.collapsible] - Whether section is collapsible
* @param {boolean} [options.collapsed] - Initial collapsed state
* @returns {string} Config section HTML
*/
export function createConfigSection({ title, content, collapsible = false, collapsed = false }) {
if (!collapsible) {
return `
`;
}
return `
`;
}
/**
* Attach collapsible section handlers
* @param {HTMLElement} container - Container element
*/
export function attachCollapsibleHandlers(container) {
const collapsibles = container.querySelectorAll('.rpg-collapsible');
collapsibles.forEach(header => {
header.addEventListener('click', () => {
const section = header.parentElement;
const content = section.querySelector('.rpg-config-content');
const icon = header.querySelector('i');
const isCollapsed = section.classList.toggle('collapsed');
if (isCollapsed) {
content.style.display = 'none';
icon.className = 'fa-solid fa-chevron-down';
} else {
content.style.display = 'block';
icon.className = 'fa-solid fa-chevron-up';
}
});
});
}
/**
* Debounce function for auto-save
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in ms
* @returns {Function} Debounced function
*/
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Safe number parser with fallback
* @param {string|number} value - Value to parse
* @param {number} fallback - Fallback value
* @param {number} [min] - Minimum value
* @param {number} [max] - Maximum value
* @returns {number} Parsed number
*/
export function parseNumber(value, fallback, min = -Infinity, max = Infinity) {
const num = typeof value === 'string' ? parseInt(value, 10) : value;
if (isNaN(num)) return fallback;
return Math.max(min, Math.min(max, num));
}
/**
* Create loading spinner
* @param {string} [text] - Loading text
* @returns {string} Loading spinner HTML
*/
export function createLoadingSpinner(text = 'Loading...') {
return `
${text}
`;
}
/**
* Create empty state message
* @param {Object} options - Empty state options
* @param {string} options.icon - Icon (emoji or FA class)
* @param {string} options.message - Message text
* @param {string} [options.action] - Optional action button HTML
* @returns {string} Empty state HTML
*/
export function createEmptyState({ icon, message, action = '' }) {
const isFontAwesome = icon.startsWith('fa-');
const iconHtml = isFontAwesome
? ``
: `${icon}`;
return `
${iconHtml}
${message}
${action}
`;
}
/**
* Escape HTML to prevent XSS
* @param {string} unsafe - Unsafe string
* @returns {string} Escaped string
*/
export function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Format number with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
export function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated text
*/
export function truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
/**
* Create responsive grid for items
* @param {Array} items - Array of item HTML
* @param {number} [columns] - Number of columns (auto if not specified)
* @param {string} [gap] - Gap size (CSS value)
* @returns {string} Grid HTML
*/
export function createGrid(items, columns = null, gap = '12px') {
const gridStyle = columns
? `grid-template-columns: repeat(${columns}, 1fr); gap: ${gap};`
: `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: ${gap};`;
return `
${items.join('')}
`;
}
/**
* Create card component
* @param {Object} options - Card options
* @param {string} options.title - Card title
* @param {string} options.content - Card content
* @param {string} [options.icon] - Optional icon
* @param {string} [options.footer] - Optional footer HTML
* @param {string} [options.className] - Additional CSS class
* @returns {string} Card HTML
*/
export function createCard({ title, content, icon = '', footer = '', className = '' }) {
const iconHtml = icon ? `${icon}` : '';
const footerHtml = footer ? `` : '';
return `
`;
}