UNPKG

styleui-components

Version:

Lightweight, modular UI component library with zero dependencies

1,395 lines (1,167 loc) 97.3 kB
/*! * 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