styleui-components
Version:
Lightweight, modular UI component library with zero dependencies
1,395 lines (1,167 loc) • 97.3 kB
JavaScript
/*!
* StyleUI - Lightweight UI Component Library
* License: MIT
*/
(function() {
'use strict';
// --- core.js ---
'use strict';
// Configuration constants
const CONFIG = {
TOAST_DURATION: 5000,
MODAL_ANIMATION_DURATION: 200,
MENU_ANIMATION_DURATION: 150,
TOOLTIP_MARGIN: 12,
POPOVER_MARGIN: 8,
MOBILE_BREAKPOINT: 768,
ACCENT_COLORS: [
{ name: 'primary', color: 'var(--primary)' },
{ name: 'success', color: 'var(--success)' },
{ name: 'warning', color: 'var(--warning)' },
{ name: 'error', color: 'var(--error)' },
{ name: 'info', color: 'var(--info)' },
{ name: 'neutral', color: 'var(--neutral)' }
],
TOAST_ICONS: {
success: 'check-circle',
error: 'x-circle',
warning: 'alert-triangle',
info: 'info'
}
};
// --- Main UI Object Definition ---
const UI = {
// Initialize icons after adding elements
icons() {
if (typeof lucide !== 'undefined') {
lucide.createIcons({ class: 'lucide' });
const savedStrokeWidth = localStorage.getItem('styleui-stroke-width');
if (savedStrokeWidth) {
document.querySelectorAll('.lucide').forEach(icon => {
icon.style.strokeWidth = savedStrokeWidth;
});
}
}
},
// Defer icon initialization
deferIcons() {
setTimeout(() => this.icons(), 0);
},
// Build CSS class string from array
buildClasses(...classes) {
return classes.filter(Boolean).join(' ');
},
// Theme management
theme: {
set(theme) {
document.body.classList.toggle('dark', theme === 'dark');
localStorage.setItem('styleui-theme', theme);
},
get() {
return document.body.classList.contains('dark') ? 'dark' : 'light';
},
toggle() {
const newTheme = this.get() === 'dark' ? 'light' : 'dark';
this.set(newTheme);
return newTheme;
}
},
language: {
translations: {}, // Will be populated by a separate language file loader if needed
set(lang) {
document.documentElement.setAttribute('lang', lang);
localStorage.setItem('styleui-lang', lang);
},
get() {
return document.documentElement.getAttribute('lang') || 'en';
},
translate(text) {
// In a real app, this would look up the text in the translations object.
// For this style guide, we'll keep it simple.
return text;
}
},
// --- Sections Management ---
sections: {
// This will be populated by individual section scripts, e.g., UI.sections.buttons = ...
createAll(sectionData) {
const container = document.getElementById('sections-container');
if (!container) {
console.error('Sections container not found.');
return;
}
sectionData.forEach(group => {
const groupHeader = document.createElement('h1');
groupHeader.className = 'group-header';
groupHeader.textContent = group.name;
groupHeader.id = `group-${group.name.toLowerCase().replace(/\s+/g, '-')}`;
container.appendChild(groupHeader);
group.children.forEach(sectionName => {
if (UI.sections[sectionName] && typeof UI.sections[sectionName] === 'function') {
const sectionElement = UI.sections[sectionName]();
if (sectionElement) {
sectionElement.id = `section-${sectionName}`;
container.appendChild(sectionElement);
}
} else {
console.error(`UI.sections.${sectionName} is not defined or not a function.`);
}
});
});
}
}
};
// Expose to window
window.UI = UI;
window.CONFIG = CONFIG;
// Auto-initialize theme from localStorage
document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('styleui-theme');
if (savedTheme) {
UI.theme.set(savedTheme);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
UI.theme.set(prefersDark ? 'dark' : 'light');
}
});
})();
// --- button.js ---
if (!window.UI) window.UI = {};
/**
* Creates a button element.
* @param {string} text - The text content of the button.
* @param {object} [options={}] - The options for the button.
* @param {string} [options.icon] - The Lucide icon name.
* @param {string} [options.variant] - The color variant (e.g., 'primary', 'success').
* @param {string} [options.size] - The size variant (e.g., 'sm', 'lg').
* @param {boolean} [options.mono] - Whether to use a monospace font.
* @param {string} [options.class] - Additional CSS classes.
* @param {function} [options.onclick] - The click event handler.
* @param {boolean} [options.disabled] - Whether the button is disabled.
* @returns {HTMLButtonElement} The button element.
*/
UI.button = function(config = {}) {
// Support legacy call signature: UI.button(text, options)
if (typeof config === 'string') {
config = { text: config, ...arguments[1] };
}
const {
text,
icon,
variant,
size,
mono,
class: customClass,
onclick,
disabled,
iconPosition = 'left'
} = config;
const btn = document.createElement('button');
const isIconOnly = icon && !text;
btn.className = UI.buildClasses(
'btn',
variant && `btn-${variant}`,
size && `btn-${size}`,
mono && 'font-mono',
isIconOnly && 'icon-only',
customClass
);
const textSpan = text ? document.createElement('span') : null;
if (textSpan) {
textSpan.textContent = text;
}
if (icon) {
const iconEl = document.createElement('i');
iconEl.setAttribute('data-lucide', icon);
iconEl.className = 'lucide';
if (iconPosition === 'right' && textSpan) {
btn.appendChild(textSpan);
btn.appendChild(iconEl);
} else {
btn.appendChild(iconEl);
if (textSpan) {
btn.appendChild(textSpan);
}
}
} else if (textSpan) {
btn.appendChild(textSpan);
}
if (onclick) btn.onclick = onclick;
if (disabled) btn.disabled = true;
if (config.attributes) {
for (const [key, value] of Object.entries(config.attributes)) {
btn.setAttribute(key, value);
}
}
UI.deferIcons();
return btn;
};
/**
* Creates a stateful icon toggle button.
* @param {object} [options={}] - The options for the button.
* @param {string} options.iconOn - The Lucide icon for the 'on' state.
* @param {string} options.iconOff - The Lucide icon for the 'off' state.
* @param {string} [options.tooltip] - Tooltip to display.
* @param {function} [options.initialState] - Function that returns the initial state (true/false).
* @param {function} [options.onchange] - Callback function when the state changes.
* @returns {HTMLButtonElement} The button element.
*/
UI.iconToggle = function(config = {}) {
const {
iconOn,
iconOff,
tooltip,
initialState,
onchange
} = config;
let isChecked = initialState ? initialState() : false;
const btn = UI.button({
icon: isChecked ? iconOff : iconOn,
onclick: (e) => {
isChecked = !isChecked;
const button = e.currentTarget;
const newIconName = isChecked ? iconOff : iconOn;
button.innerHTML = `<i data-lucide="${newIconName}"></i>`;
lucide.createIcons({
nodes: [button]
});
if (onchange) onchange(isChecked);
}
});
if (tooltip) {
UI.tooltip(btn, tooltip);
}
// Initial state action
if (onchange) {
onchange(isChecked);
}
return btn;
};
/**
* Creates a button that cycles through multiple states.
* @param {object} [config={}] - The options for the button.
* @param {Array<object>} config.states - Array of state objects { value: string, icon: string }.
* @param {function} [config.initialState] - Function that returns the initial value.
* @param {function} [config.onchange] - Callback with the new value.
* @param {string} [config.tooltip] - Base tooltip text.
* @returns {HTMLButtonElement} The button element.
*/
UI.cycleButton = function(config = {}) {
const {
states,
initialState,
onchange,
tooltip
} = config;
let currentIndex = 0;
if (initialState) {
const initialValue = initialState();
const foundIndex = states.findIndex(s => s.value === initialValue);
if (foundIndex !== -1) {
currentIndex = foundIndex;
}
}
const btn = UI.button({
icon: states[currentIndex].icon,
onclick: (e) => {
currentIndex = (currentIndex + 1) % states.length;
const newState = states[currentIndex];
const button = e.currentTarget;
button.innerHTML = `<i data-lucide="${newState.icon}"></i>`;
lucide.createIcons({ nodes: [button] });
if (tooltip) {
UI.tooltip(button, `${tooltip}: ${newState.value}`);
}
if (onchange) {
onchange(newState.value);
}
}
});
if (tooltip) {
UI.tooltip(btn, `${tooltip}: ${states[currentIndex].value}`);
}
if (onchange) {
onchange(states[currentIndex].value);
}
return btn;
};
/**
* Creates a button that cycles through multiple color states.
* @param {object} [config={}] - The options for the button.
* @param {Array<object>} config.states - Array of state objects { value: string, colorVar: string }.
* @param {function} [config.initialState] - Function that returns the initial value.
* @param {function} [config.onchange] - Callback with the new state object.
* @param {string} [config.tooltip] - Base tooltip text.
* @returns {HTMLButtonElement} The button element.
*/
UI.cycleSwatch = function(config = {}) {
const {
states,
initialState,
onchange,
tooltip
} = config;
let currentIndex = 0;
if (initialState) {
const initialValue = initialState();
const foundIndex = states.findIndex(s => s.value === initialValue);
if (foundIndex !== -1) {
currentIndex = foundIndex;
}
}
const btn = document.createElement('button');
btn.className = 'btn btn-swatch';
const updateState = (index, isInitial = false) => {
currentIndex = index;
const state = states[currentIndex];
btn.style.backgroundColor = `var(${state.colorVar})`;
if (tooltip) {
UI.tooltip(btn, `${tooltip}: ${state.value}`);
}
if (onchange && !isInitial) {
onchange(state);
}
};
btn.addEventListener('click', () => {
const nextIndex = (currentIndex + 1) % states.length;
updateState(nextIndex);
});
updateState(currentIndex, true);
if (onchange) {
// Set initial state without triggering animation/logic
onchange(states[currentIndex]);
}
return btn;
};
/**
* Creates a color swatch button.
* @param {object} [options={}] - The options for the swatch.
* @param {string} options.color - The CSS variable for the color (e.g., 'var(--primary)').
* @param {string} [options.tooltip] - Tooltip to display.
* @param {function} [options.onclick] - Callback function when clicked.
* @returns {HTMLButtonElement} The button element.
*/
UI.swatch = function(config = {}) {
const {
color,
tooltip,
onclick
} = config;
const btn = document.createElement('button');
btn.className = 'btn btn-swatch';
btn.style.backgroundColor = color;
if (onclick) {
btn.addEventListener('click', onclick);
}
if (tooltip) {
UI.tooltip(btn, tooltip);
}
return btn;
};
})();
// --- card.js ---
if (!window.UI) window.UI = {};
/**
* Creates a card element.
* @param {string} title - The title of the card.
* @param {string} content - The HTML content for the card body.
* @param {object} [options={}] - Options for the card.
* @param {string} [options.icon] - Lucide icon name for the header.
* @param {Array<object>} [options.actions] - Action buttons for the header.
* @param {string} [options.description] - Text for the card footer.
* @param {string} [options.footer] - HTML content for the card footer.
* @param {string} [options.class] - Additional CSS classes for the card.
* @returns {HTMLElement} The card element.
*/
UI.card = function(config) {
const { title, content, ...options } = config;
const card = document.createElement('div');
card.className = UI.buildClasses('card', options.class);
if (title) {
const cardHeader = document.createElement('div');
cardHeader.className = 'card-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'card-header-left';
if (options.icon) {
const icon = document.createElement('i');
icon.setAttribute('data-lucide', options.icon);
icon.className = 'card-icon lucide';
headerLeft.appendChild(icon);
}
const cardTitle = document.createElement('h3');
cardTitle.className = 'card-title';
cardTitle.textContent = title;
headerLeft.appendChild(cardTitle);
cardHeader.appendChild(headerLeft);
if (options.actions) {
const headerActions = document.createElement('div');
headerActions.className = 'card-header-actions';
options.actions.forEach(action => {
const btn = UI.button(action.text || '', {
icon: action.icon,
size: 'sm',
variant: action.variant,
onclick: action.onclick,
class: 'card-action-btn'
});
headerActions.appendChild(btn);
});
cardHeader.appendChild(headerActions);
}
card.appendChild(cardHeader);
}
const cardBody = document.createElement('div');
cardBody.className = 'card-body';
// If content is a string, set it as innerHTML. If it's an element, append it.
if (typeof content === 'string') {
cardBody.innerHTML = content;
} else if (content instanceof HTMLElement) {
cardBody.appendChild(content);
}
card.appendChild(cardBody);
if (options.description || options.footer) {
const cardFooter = document.createElement('div');
cardFooter.className = 'card-footer';
if (options.description) {
const p = document.createElement('p');
p.className = 'card-description';
p.textContent = options.description;
cardFooter.appendChild(p);
}
if (options.footer) {
const footerContent = document.createElement('div');
if (typeof options.footer === 'string') {
footerContent.innerHTML = options.footer;
} else if (options.footer instanceof HTMLElement) {
footerContent.appendChild(options.footer);
}
cardFooter.appendChild(footerContent);
}
card.appendChild(cardFooter);
}
UI.deferIcons();
return card;
};
})();
// --- form.js ---
'use strict';
if (!window.UI) {
window.UI = {};
}
(function(UI) {
/**
* Creates a form group with a label and an input control.
* @param {object} config - The configuration for the form group.
* @param {string} config.id - The unique ID for the input, used for the 'for' attribute.
* @param {string} config.label - The text for the label.
* @param {string} [config.type='text'] - The type of the input control (e.g., 'text', 'email', 'textarea').
* @param {string} [config.placeholder=''] - The placeholder text for the input.
* @param {string} [config.value=''] - The initial value of the input.
* @returns {HTMLElement} The form group element.
*/
UI.formGroup = function(config) {
const { id, label, type = 'text', placeholder = '', value = '' } = config;
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const labelEl = document.createElement('label');
labelEl.setAttribute('for', id);
labelEl.textContent = label;
formGroup.appendChild(labelEl);
let controlEl;
if (type === 'textarea') {
controlEl = document.createElement('textarea');
controlEl.textContent = value;
} else {
controlEl = document.createElement('input');
controlEl.type = type;
controlEl.value = value;
}
controlEl.id = id;
controlEl.className = 'form-control';
controlEl.placeholder = placeholder;
formGroup.appendChild(controlEl);
return formGroup;
};
/**
* Creates a custom select/dropdown component that is fully stylable.
* @param {object} config - The configuration for the select group.
* @param {string} config.id - The unique ID for the select.
* @param {string} config.label - The text for the label.
* @param {Array<object>} config.options - Array of options. {value: string, text: string}
* @returns {HTMLElement} The form group element.
*/
UI.customSelectGroup = function(config) {
const { id, label, options = [] } = config;
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const labelEl = document.createElement('label');
labelEl.setAttribute('for', id);
labelEl.textContent = label;
formGroup.appendChild(labelEl);
const selectContainer = document.createElement('div');
selectContainer.className = 'custom-select';
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = id;
hiddenInput.name = id;
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'form-control custom-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const triggerValue = document.createElement('span');
trigger.appendChild(triggerValue);
const panel = document.createElement('div');
panel.className = 'custom-select-panel';
panel.setAttribute('role', 'listbox');
options.forEach((opt, index) => {
const optionEl = document.createElement('div');
optionEl.className = 'custom-select-option';
optionEl.dataset.value = opt.value;
optionEl.textContent = opt.text;
optionEl.setAttribute('role', 'option');
optionEl.setAttribute('aria-selected', 'false');
optionEl.tabIndex = -1;
if (index === 0) {
triggerValue.textContent = opt.text;
hiddenInput.value = opt.value;
optionEl.classList.add('selected');
optionEl.setAttribute('aria-selected', 'true');
}
optionEl.addEventListener('click', () => {
triggerValue.textContent = optionEl.textContent;
hiddenInput.value = optionEl.dataset.value;
panel.querySelectorAll('.custom-select-option').forEach(o => {
o.classList.remove('selected');
o.setAttribute('aria-selected', 'false');
});
optionEl.classList.add('selected');
optionEl.setAttribute('aria-selected', 'true');
closePanel();
});
panel.appendChild(optionEl);
});
selectContainer.appendChild(hiddenInput);
selectContainer.appendChild(trigger);
selectContainer.appendChild(panel);
const openPanel = () => {
selectContainer.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
document.addEventListener('click', handleOutsideClick, true);
};
const closePanel = () => {
selectContainer.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', handleOutsideClick, true);
};
const handleOutsideClick = (e) => {
if (!selectContainer.contains(e.target)) {
closePanel();
}
};
trigger.addEventListener('click', (e) => {
e.stopPropagation();
if (selectContainer.classList.contains('open')) {
closePanel();
} else {
openPanel();
}
});
formGroup.appendChild(selectContainer);
return formGroup;
};
/**
* Creates a single checkbox control.
* @param {object} config - The configuration for the checkbox.
* @param {string} config.id - The unique ID for the input.
* @param {string} config.label - The text for the label.
* @param {string} [config.name] - The name for the input.
* @param {boolean} [config.checked=false] - If the checkbox is checked by default.
* @param {string} [config.variant] - Semantic color variant.
* @returns {HTMLElement} The checkbox label element containing the input and custom checkmark.
*/
UI.checkbox = function(config) {
const { id, label, name, checked = false, variant } = config;
const labelEl = document.createElement('label');
labelEl.className = `checkbox ${variant ? `checkbox-${variant}` : ''}`;
labelEl.setAttribute('for', id);
const inputEl = document.createElement('input');
inputEl.type = 'checkbox';
inputEl.id = id;
if (name) inputEl.name = name;
inputEl.checked = checked;
const checkmark = document.createElement('span');
checkmark.className = 'checkmark';
const textSpan = document.createElement('span');
textSpan.textContent = label;
labelEl.appendChild(inputEl);
labelEl.appendChild(checkmark);
labelEl.appendChild(textSpan);
return labelEl;
};
/**
* Creates a single radio button control.
* @param {object} config - The configuration for the radio button.
* @param {string} config.id - The unique ID for the input.
* @param {string} config.label - The text for the label.
* @param {string} config.name - The name for the input (required for grouping).
* @param {boolean} [config.checked=false] - If the radio is checked by default.
* @param {string} [config.variant] - Semantic color variant.
* @returns {HTMLElement} The radio label element containing the input and custom radiomark.
*/
UI.radio = function(config) {
const { id, label, name, checked = false, variant } = config;
const labelEl = document.createElement('label');
labelEl.className = `radio ${variant ? `radio-${variant}` : ''}`;
labelEl.setAttribute('for', id);
const inputEl = document.createElement('input');
inputEl.type = 'radio';
inputEl.id = id;
inputEl.name = name; // Important for radio group behavior
inputEl.checked = checked;
const radiomark = document.createElement('span');
radiomark.className = 'radiomark';
const textSpan = document.createElement('span');
textSpan.textContent = label;
labelEl.appendChild(inputEl);
labelEl.appendChild(radiomark);
labelEl.appendChild(textSpan);
return labelEl;
};
/**
* Creates a range slider form group.
* @param {object} config - The configuration for the range slider.
* @param {string} config.id - The unique ID for the input.
* @param {string} config.label - The text for the label.
* @param {number} [config.min=0] - The minimum value.
* @param {number} [config.max=100] - The maximum value.
* @param {number} [config.step=1] - The step increment.
* @param {number} [config.value=50] - The initial value.
* @returns {HTMLElement} The form group element.
*/
UI.rangeGroup = function(config) {
const { id, label, min = 0, max = 100, step = 1, value = 50 } = config;
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const labelWrapper = document.createElement('div');
labelWrapper.style.display = 'flex';
labelWrapper.style.justifyContent = 'space-between';
labelWrapper.style.alignItems = 'center';
labelWrapper.style.marginBottom = 'var(--space-2)';
const labelEl = document.createElement('label');
labelEl.setAttribute('for', id);
labelEl.textContent = label;
labelEl.style.marginBottom = '0'; // Override default margin
const valueSpan = document.createElement('span');
valueSpan.className = 'text-secondary font-mono';
valueSpan.textContent = value;
labelWrapper.appendChild(labelEl);
labelWrapper.appendChild(valueSpan);
const inputEl = document.createElement('input');
inputEl.type = 'range';
inputEl.id = id;
inputEl.className = 'form-control';
inputEl.min = min;
inputEl.max = max;
inputEl.step = step;
inputEl.value = value;
inputEl.addEventListener('input', (e) => {
valueSpan.textContent = e.target.value;
});
formGroup.appendChild(labelWrapper);
formGroup.appendChild(inputEl);
return formGroup;
};
/**
* Creates a themed date picker form group.
* @param {object} config - The configuration for the date picker.
* @param {string} config.id - The unique ID for the input.
* @param {string} config.label - The text for the label.
* @param {string} [config.value] - The initial date value.
* @returns {HTMLElement} The form group element.
*/
UI.dateGroup = function(config) {
const { id, label, value } = config;
const formGroup = UI.formGroup({ id, label, type: 'text', value: value || '', placeholder: 'Select a date' });
const inputEl = formGroup.querySelector('input');
// Wrap input to add icon
const inputWrapper = document.createElement('div');
inputWrapper.style.position = 'relative';
const icon = document.createElement('i');
icon.setAttribute('data-lucide', 'calendar');
icon.style.position = 'absolute';
icon.style.right = 'var(--space-3)';
icon.style.top = '50%';
icon.style.transform = 'translateY(-50%)';
icon.style.pointerEvents = 'none';
icon.style.color = 'var(--text-tertiary)';
inputWrapper.appendChild(inputEl.cloneNode(true));
inputWrapper.appendChild(icon);
formGroup.replaceChild(inputWrapper, inputEl);
const realInput = formGroup.querySelector('input');
// Defer initialization
setTimeout(() => {
new Datepicker(realInput, {
autohide: true,
format: 'yyyy-mm-dd',
buttonClass: 'btn'
});
UI.deferIcons();
}, 0);
return formGroup;
};
/**
* Creates a themed color picker form group.
* @param {object} config - The configuration for the color picker.
* @param {string} config.id - The unique ID for the input.
* @param {string} config.label - The text for the label.
* @param {string} [config.value='#b5d3b6'] - The initial color value.
* @returns {HTMLElement} The form group element.
*/
UI.colorGroup = function(config) {
const { id, label, value = '#b5d3b6' } = config;
const formGroup = document.createElement('div');
formGroup.className = 'form-group';
const labelEl = document.createElement('label');
labelEl.setAttribute('for', id);
labelEl.textContent = label;
formGroup.appendChild(labelEl);
const wrapper = document.createElement('div');
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'center';
wrapper.style.gap = 'var(--space-2)';
const colorSwatch = document.createElement('button');
colorSwatch.type = 'button';
colorSwatch.className = 'form-control';
colorSwatch.style.width = '48px';
colorSwatch.style.height = '38px';
colorSwatch.style.flexShrink = '0';
colorSwatch.style.padding = 'var(--space-1)';
colorSwatch.style.backgroundColor = value;
const inputEl = document.createElement('input');
inputEl.type = 'text';
inputEl.id = id;
inputEl.className = 'form-control';
inputEl.value = value;
wrapper.appendChild(colorSwatch);
wrapper.appendChild(inputEl);
formGroup.appendChild(wrapper);
// Defer initialization
setTimeout(() => {
const picker = new Picker({
parent: colorSwatch,
popup: 'right', // 'right'(default), 'left', 'top', 'bottom'
color: value,
editor: false,
alpha: false
});
picker.onChange = function(color) {
const hex = color.hex.slice(0, 7);
colorSwatch.style.backgroundColor = hex;
inputEl.value = hex;
};
inputEl.addEventListener('change', () => {
picker.setColor(inputEl.value, true);
});
}, 0);
return formGroup;
};
}(window.UI));
// --- menu.js ---
if (!window.UI) {
window.UI = {};
}
(function(UI) {
/**
* Creates menu items, including sub-menus, from an array of item data.
* This is an internal helper function.
* @param {Array<object>} items - The items for the menu.
* @param {function} closeMenuCallback - The function to call when an item is clicked.
* @returns {DocumentFragment} - A fragment containing the menu items.
*/
const createMenuItems = (items, closeMenuCallback) => {
const fragment = document.createDocumentFragment();
items.forEach(itemData => {
if (itemData.type === 'divider') {
const divider = document.createElement('div');
divider.className = 'menu-divider';
divider.setAttribute('role', 'separator');
fragment.appendChild(divider);
return;
}
const menuItem = document.createElement('div');
menuItem.className = 'menu-item';
menuItem.setAttribute('role', 'menuitem');
menuItem.tabIndex = -1;
if (itemData.icon) {
const icon = document.createElement('i');
icon.setAttribute('data-lucide', itemData.icon);
menuItem.appendChild(icon);
}
const text = document.createElement('span');
text.textContent = itemData.text;
menuItem.appendChild(text);
if (itemData.children && itemData.children.length > 0) {
menuItem.classList.add('has-submenu');
const subMenuIcon = document.createElement('i');
subMenuIcon.setAttribute('data-lucide', 'chevron-right');
menuItem.appendChild(subMenuIcon);
const subMenuPanel = document.createElement('div');
subMenuPanel.className = 'menu-panel';
const subMenuItems = createMenuItems(itemData.children, closeMenuCallback);
subMenuPanel.appendChild(subMenuItems);
menuItem.appendChild(subMenuPanel);
} else {
if (itemData.shortcut) {
const shortcut = document.createElement('span');
shortcut.className = 'menu-shortcut tag';
shortcut.textContent = itemData.shortcut;
menuItem.appendChild(shortcut);
}
if (itemData.onClick) {
menuItem.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent closing parent menus
itemData.onClick();
if (closeMenuCallback) {
closeMenuCallback();
}
});
}
}
fragment.appendChild(menuItem);
});
return fragment;
};
/**
* Creates a menu component.
* @param {object} config - The configuration for the menu.
* @param {object} config.trigger - The configuration for the trigger button.
* @param {string} config.trigger.text - The text for the trigger button.
* @param {string} [config.trigger.icon] - The Lucide icon for the trigger button.
* @param {Array<object>} config.items - The items for the menu.
* @param {string} [config.placement] - The placement of the menu ('top', 'right', 'bottom', 'left').
* @returns {HTMLElement} - The menu component.
*/
UI.menu = (config) => {
const { trigger, items, placement } = config;
const menuContainer = document.createElement('div');
menuContainer.className = 'menu';
const triggerConfig = {
iconPosition: 'right',
variant: 'secondary',
...trigger,
attributes: {
'aria-haspopup': 'true',
'aria-expanded': 'false',
...(trigger.attributes || {})
}
};
const triggerButton = UI.button(triggerConfig);
triggerButton.classList.add('menu-trigger');
const menuPanel = document.createElement('div');
menuPanel.className = 'menu-panel';
menuPanel.setAttribute('role', 'menu');
const closeMenu = () => {
menuContainer.classList.remove('open', 'menu--top', 'menu--right', 'menu--left');
triggerButton.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', handleOutsideClick, true);
const parentPanel = menuContainer.closest('.panel');
if (parentPanel) {
parentPanel.classList.remove('panel--active');
}
};
const menuItems = createMenuItems(items, closeMenu);
menuPanel.appendChild(menuItems);
menuContainer.appendChild(triggerButton);
menuContainer.appendChild(menuPanel);
UI.deferIcons();
const toggleMenu = (e) => {
e.stopPropagation();
const isOpen = menuContainer.classList.contains('open');
document.querySelectorAll('.menu.open').forEach(openMenu => {
if (openMenu !== menuContainer) {
openMenu.classList.remove('open', 'menu--top', 'menu--right', 'menu--left');
openMenu.querySelector('.menu-trigger').setAttribute('aria-expanded', 'false');
const otherPanel = openMenu.closest('.panel');
if (otherPanel) {
otherPanel.classList.remove('panel--active');
}
}
});
if (isOpen) {
closeMenu();
} else {
const parentPanel = menuContainer.closest('.panel');
if (parentPanel) {
parentPanel.classList.add('panel--active');
}
let finalPlacement = placement;
if (!finalPlacement) {
const triggerRect = triggerButton.getBoundingClientRect();
menuPanel.style.visibility = 'hidden';
menuPanel.style.display = 'block';
const panelHeight = menuPanel.offsetHeight;
menuPanel.style.visibility = '';
menuPanel.style.display = '';
const spaceBelow = window.innerHeight - triggerRect.bottom;
if (spaceBelow < panelHeight && triggerRect.top > panelHeight) {
finalPlacement = 'top';
}
}
if (finalPlacement) {
menuContainer.classList.add(`menu--${finalPlacement}`);
}
menuContainer.classList.add('open');
triggerButton.setAttribute('aria-expanded', 'true');
document.addEventListener('click', handleOutsideClick, true);
}
};
const handleOutsideClick = (e) => {
if (!menuContainer.contains(e.target)) {
closeMenu();
}
};
triggerButton.addEventListener('click', toggleMenu);
return menuContainer;
};
/**
* Attaches a context menu to a target element.
* @param {HTMLElement} target - The element to attach the context menu to.
* @param {Array<object>} items - The items for the menu.
*/
UI.contextMenu = (target, items) => {
const showMenu = (e) => {
e.preventDefault();
// Remove any existing context menu
hideMenu();
let container = document.getElementById('context-menu-container');
if (!container) {
container = document.createElement('div');
container.id = 'context-menu-container';
document.body.appendChild(container);
}
const menuPanel = document.createElement('div');
menuPanel.className = 'menu-panel';
menuPanel.style.display = 'block'; // Make it visible to calculate size
menuPanel.style.position = 'static';
const menuItems = createMenuItems(items, hideMenu);
menuPanel.appendChild(menuItems);
container.appendChild(menuPanel);
UI.icons();
const { clientX, clientY } = e;
const { innerWidth, innerHeight } = window;
const { offsetWidth, offsetHeight } = menuPanel;
let top = clientY;
let left = clientX;
if (clientY + offsetHeight > innerHeight) { top = innerHeight - offsetHeight - 5; }
if (clientX + offsetWidth > innerWidth) { left = innerWidth - offsetWidth - 5; }
container.style.top = `${top}px`;
container.style.left = `${left}px`;
document.addEventListener('click', hideMenu, { once: true, capture: true });
};
const hideMenu = (e) => {
let container = document.getElementById('context-menu-container');
if (container) {
if (e && container.contains(e.target)) {
document.addEventListener('click', hideMenu, { once: true, capture: true });
return;
}
container.innerHTML = '';
document.removeEventListener('click', hideMenu, { once: true, capture: true });
}
};
target.addEventListener('contextmenu', showMenu);
return {
destroy: () => {
target.removeEventListener('contextmenu', showMenu);
}
};
};
}(window.UI));
// --- modal.js ---
if (!window.UI) window.UI = {};
/**
* Shows a modal dialog.
* @param {string|HTMLElement} content - The HTML string or element for the modal body.
* @param {object} [options={}] - Options for the modal.
* @param {string} [options.title] - The title for the modal header.
* @param {string} [options.icon] - Lucide icon name for the header.
* @param {string} [options.size] - The size variant (e.g., 'sm', 'lg').
* @param {Array<object>} [options.actions] - Action buttons for the footer.
* @param {boolean} [options.closeOnBackdrop=true] - If the modal should close when clicking the backdrop.
* @param {function} [options.onclose] - Callback function when the modal is closed.
* @returns {{modal: HTMLElement, backdrop: HTMLElement, close: function}} The modal elements and close function.
*/
UI.modal = function(config = {}) {
const {
content,
title = '',
icon,
size,
actions = [],
closeOnBackdrop = true,
onclose
} = config;
const modal = document.createElement('div');
modal.className = this.buildClasses('modal', size && `modal-${size}`);
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
let cardFooter = null;
if (actions.length > 0) {
cardFooter = document.createElement('div');
// The card component will add its own footer class.
actions.forEach(action => {
const btn = this.button(action.text, {
variant: action.variant,
size: action.size,
onclick: () => {
if (action.onclick) action.onclick();
if (action.closeModal !== false) closeModal();
}
});
cardFooter.appendChild(btn);
});
}
const card = UI.card({
title,
content,
icon,
footer: cardFooter,
actions: title ? [{
icon: 'x',
onclick: () => closeModal(),
ariaLabel: 'Close'
}] : null
});
modal.appendChild(card);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this.icons();
requestAnimationFrame(() => {
backdrop.classList.add('show');
modal.classList.add('show');
});
const closeModal = () => {
backdrop.classList.remove('show');
modal.classList.remove('show');
// Allow animation to complete before removing from DOM
setTimeout(() => {
backdrop.remove();
document.removeEventListener('keydown', escapeHandler);
}, CONFIG.MODAL_ANIMATION_DURATION); // Match this with --transition-base
if (onclose) onclose();
};
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop && closeOnBackdrop) {
closeModal();
}
};
document.addEventListener('keydown', escapeHandler);
return { modal, backdrop, close: closeModal };
};
})();
// --- panel.js ---
'use strict';
// Ensure the UI namespace exists
window.UI = window.UI || {};
(function(UI) {
/**
* Creates a panel component.
* @param {string} title - The title of the panel.
* @param {string|HTMLElement} content - The content of the panel.
* @param {object} [options={}] - The options for the panel.
* @param {string} [options.icon] - The icon to display in the panel header.
* @param {boolean} [options.collapsible=false] - Whether the panel is collapsible.
* @param {boolean} [options.startCollapsed=false] - Whether the panel should be collapsed initially.
* @returns {HTMLElement} The panel element.
*/
UI.panel = function(title, content, options = {}) {
const panel = document.createElement('div');
panel.className = 'panel';
if (options.collapsible) {
panel.classList.add('panel-collapsible');
}
if (options.collapsible && options.startCollapsed) {
panel.classList.add('panel-collapsed');
}
// Header
const header = document.createElement('div');
header.className = 'panel-header';
// Title
const titleDiv = document.createElement('div');
titleDiv.className = 'panel-title';
if (options.icon) {
const icon = document.createElement('i');
icon.setAttribute('data-lucide', options.icon);
icon.className = 'lucide';
titleDiv.appendChild(icon);
}
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
titleDiv.appendChild(titleSpan);
// Actions
const actionsDiv = document.createElement('div');
actionsDiv.className = 'panel-actions';
let toggleButton;
if (options.collapsible) {
toggleButton = UI.button({ icon: 'chevron-down' });
toggleButton.classList.add('panel-toggle');
actionsDiv.appendChild(toggleButton);
}
header.appendChild(titleDiv);
header.appendChild(actionsDiv);
// Body
const body = document.createElement('div');
body.className = 'panel-body';
if (typeof content === 'string') {
body.innerHTML = content;
} else if (content instanceof HTMLElement) {
body.appendChild(content);
}
panel.appendChild(header);
panel.appendChild(body);
// Add collapse functionality
if (options.collapsible && toggleButton) {
toggleButton.onclick = () => {
panel.classList.toggle('panel-collapsed');
};
}
// Initialize icons
UI.deferIcons();
return panel;
};
}(window.UI));
// --- toast.js ---
if (!window.UI) window.UI = {};
/**
* Shows a toast notification.
* @param {string} message - The message to display.
* @param {string} [type='info'] - The type of toast (info, success, warning, error).
* @param {object} [options={}] - Options for the toast.
* @param {number} [options.duration] - How long the toast appears in ms.
* @param {boolean} [options.dismissible] - If the toast can be closed by the user.
* @param {object} [options.action] - An action button to show on the toast.
* @param {string} options.action.text - The text for the action button.
* @param {function} options.action.callback - The function to call when the action is clicked.
* @param {boolean} [options.preloader] - Show a loading spinner instead of an icon.
* @returns {HTMLElement} The toast element.
*/
UI.toast = function(message, type = 'info', options = {}) {
message = UI.language.translate(message);
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = CONFIG.TOAST_ICONS;
let iconHTML = '';
if (options.preloader) {
const spinnerClas