From 6479a559ef243b80a0960eb88fde59c08f49a3d8 Mon Sep 17 00:00:00 2001 From: natxocc Date: Mon, 23 Mar 2026 15:17:05 +0100 Subject: [PATCH] Update sigpro and UI --- plugins/ui.js | 556 +++++++++++++++++++++++++++++++++++------------ sigpro/sigpro.js | 372 ++++++++++++++++++------------- 2 files changed, 640 insertions(+), 288 deletions(-) diff --git a/plugins/ui.js b/plugins/ui.js index 0b75cc7..ad4b744 100644 --- a/plugins/ui.js +++ b/plugins/ui.js @@ -1,207 +1,493 @@ /** * SigPro UI - daisyUI v5 & Tailwind v4 Plugin - * Provides a set of reactive functional components. + * Provides a set of reactive functional components, flow control and i18n. */ - -export const UI = ($) => { +export const UI = ($, defaultLang = 'es') => { const ui = {}; + // --- I18N CORE --- + const i18n = { + es: { close: "Cerrar", confirm: "Confirmar", cancel: "Cancelar", search: "Buscar...", loading: "Cargando..." }, + en: { close: "Close", confirm: "Confirm", cancel: "Cancel", search: "Search...", loading: "Loading..." } + }; + + const currentLocale = $(defaultLang); + /** - * Internal helper to merge base classes with reactive or static extra classes. - * @param {string} base - The default daisyUI class. - * @param {string|function} extra - User-provided classes. - * @returns {string|function} Merged classes. + * Sets the current locale for internationalization + * @param {string} locale - The locale code to set (e.g., 'es', 'en') */ - const parseClass = (base, extra) => { - if (typeof extra === 'function') return () => `${base} ${extra() || ''}`; - return `${base} ${extra || ''}`; + ui._setLocale = (locale) => currentLocale(locale); + + /** + * Returns a function that retrieves a translated string for the current locale + * @param {string} key - The translation key to look up + * @returns {Function} Function that returns the translated string + */ + const translate = (key) => () => i18n[currentLocale()][key] || key; + + // --- UTILITY FUNCTIONS --- + + /** + * Conditional rendering component + * @param {Function} condition - Signal function that returns boolean + * @param {*} thenValue - Content to render when condition is true + * @param {*} otherwiseValue - Content to render when condition is false + * @returns {Function} Function that returns appropriate content based on condition + */ + ui._if = (condition, thenValue, otherwiseValue = null) => { + return () => condition() ? thenValue : otherwiseValue; }; /** - * Standard Button component. - * @param {Object} p - Properties. - * @param {string|function} [p.class] - Extra CSS classes. - * @param {function} [p.$loading] - Reactive loading state. - * @param {function} [p.$disabled] - Reactive disabled state. - * @param {HTMLElement|string} [p.icon] - Leading icon. - * @param {string} [p.badge] - Badge text. - * @param {any} c - Children content. + * List rendering component that efficiently updates when the source array changes + * @param {Function} sourceSignal - Signal function that returns an array of items + * @param {Function} renderCallback - Callback that renders each item (item, index) => DOM element + * @returns {HTMLElement} Container element that holds the rendered list */ - ui._button = (p, c) => button({ - ...p, - class: parseClass('btn', p.$class || p.class), - $disabled: () => p.$disabled?.() || p.$loading?.() - }, [ - () => p.$loading?.() ? span({ class: 'loading loading-spinner' }) : null, - p.icon && span({ class: 'mr-1' }, p.icon), - c, - p.badge && span({ class: `badge ${p.badgeClass || ''}` }, p.badge) - ]); + ui._for = (sourceSignal, renderCallback) => { + const itemCache = new Map(); + const markerNode = document.createTextNode(''); + const container = $.html('div', { style: 'display:contents' }, [markerNode]); + + $(() => { + const items = sourceSignal() || []; + const newCache = new Map(); + const parent = markerNode.parentNode; + if (!parent) return; + + items.forEach((item, index) => { + if (itemCache.has(item)) { + const cached = itemCache.get(item); + newCache.set(item, cached); + parent.insertBefore(cached.element, markerNode); + itemCache.delete(item); + } else { + const element = $.html('div', { style: 'display:contents' }, [renderCallback(item, index)]); + newCache.set(item, { + element, + cleanup: () => { + if (element._cleanups) element._cleanups.forEach(cleanupFn => cleanupFn()); + } + }); + parent.insertBefore(element, markerNode); + } + }); + + itemCache.forEach(cached => { + if (cached.cleanup) cached.cleanup(); + cached.element.remove(); + }); + + itemCache.clear(); + newCache.forEach((value, key) => itemCache.set(key, value)); + }); + + return container; + }; + + // --- INTERNAL HELPERS --- /** - * Form Input with label, tooltip, and error handling. - * @param {Object} p - Input properties. - * @param {string} [p.label] - Field label. - * @param {string} [p.tip] - Tooltip text. - * @param {function} [p.$value] - Reactive signal for the value. - * @param {function} [p.$error] - Reactive signal for error messages. + * Combines base CSS classes with conditional or static extra classes + * @param {string} baseClasses - Base class names to always include + * @param {string|Function} extraClasses - Additional classes or function returning classes + * @returns {string|Function} Combined classes or function that returns combined classes */ - ui._input = (p) => label({ class: 'fieldset-label flex flex-col gap-1' }, [ - p.label && div({ class: 'flex items-center gap-2' }, [ - span(p.label), - p.tip && div({ class: 'tooltip tooltip-right', 'data-tip': p.tip }, - span({ class: 'badge badge-ghost badge-xs' }, '?')) - ]), + const combineClasses = (baseClasses, extraClasses) => { + if (typeof extraClasses === 'function') return () => `${baseClasses} ${extraClasses() || ''}`; + return `${baseClasses} ${extraClasses || ''}`; + }; + + // --- UI COMPONENTS --- + + /** + * Button component with loading state, icon, indicator badge, and tooltip support + * @param {Object} props - Button properties + * @param {string|number|Function} [props.badge] - Content for the indicator badge + * @param {string} [props.badgeClass] - daisyUI classes for the badge (e.g., 'badge-primary') + * @param {HTMLElement|string} [props.icon] - Icon element or string to place before children + * @param {string} [props.tooltip] - Text to display in the daisyUI tooltip + * @param {Function} [props.$loading] - SigPro Signal: if true, shows spinner and disables button + * @param {Function} [props.$disabled] - SigPro Signal: if true, disables the button + * @param {string|Function} [props.$class] - Additional reactive or static CSS classes for the button + * @param {Function} [props.onclick] - Click event handler + * @param {*} children - Button text or inner content + * @returns {HTMLElement} Button element (wrapped in indicator/tooltip containers if props are present) + */ + ui._button = (props, children) => { + const btnEl = button({ + ...props, + badge: undefined, + badgeClass: undefined, + tooltip: undefined, + class: combineClasses('btn', props.$class || props.class), + $disabled: () => props.$disabled?.() || props.$loading?.() + }, [ + ui._if(() => props.$loading?.(), span({ class: 'loading loading-spinner' })), + props.icon && span({ class: 'mr-1' }, props.icon), + children + ]); + + let out = btnEl; + + if (props.badge) { + out = div({ class: 'indicator' }, [ + span({ class: combineClasses(`indicator-item badge ${props.badgeClass || 'badge-secondary'}`) }, props.badge), + out + ]); + } + + if (props.tooltip) { + out = div({ class: 'tooltip', 'data-tip': props.tooltip }, out); + } + + return out; + }; + + /** + * Input component with label, tooltip, error state, and search placeholder support + * @param {Object} props - Input properties + * @param {string} [props.label] - Text for the input label + * @param {string} [props.tip] - Contextual help text displayed in a tooltip next to the label + * @param {boolean} [props.isSearch] - If true, uses internationalized "search" placeholder if none provided + * @param {Function} [props.$value] - SigPro Signal for two-way data binding + * @param {Function} [props.$error] - SigPro Signal returning an error message string to display + * @param {string|Function} [props.$class] - Additional reactive or static CSS classes for the input + * @param {string} [props.placeholder] - Standard HTML placeholder attribute + * @param {string} [props.type] - Standard HTML input type (text, password, email, etc.) + * @param {Function} [props.oninput] - Event handler for input changes (handled automatically if $value is a signal) + * @returns {HTMLElement} Label wrapper containing the label, tooltip, input, and error message + */ + ui._input = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [ + ui._if(() => props.label, div({ class: 'flex items-center gap-2' }, [ + span(props.label), + ui._if(() => props.tip, div({ class: 'tooltip tooltip-right', 'data-tip': props.tip }, + span({ class: 'badge badge-ghost badge-xs' }, '?'))) + ])), $.html('input', { - ...p, - class: parseClass('input input-bordered w-full', p.$class || p.class), - $value: p.$value + ...props, + placeholder: props.placeholder || (props.isSearch ? translate('search') : ''), + class: combineClasses('input input-bordered w-full', props.$class || props.class), + $value: props.$value }), - () => p.$error?.() ? span({ class: 'text-error text-xs' }, p.$error()) : null + ui._if(() => props.$error?.(), span({ class: 'text-error text-xs' }, () => props.$error())) ]); /** - * Select dropdown component. - * @param {Object} p - Select properties. - * @param {Array<{value: any, label: string}>} p.options - Array of options. - * @param {function} p.$value - Reactive signal for the selected value. + * Select dropdown component with reactive options and value binding + * @param {Object} props - Select properties + * @returns {HTMLElement} Select wrapper element */ - ui._select = (p) => label({ class: 'fieldset-label flex flex-col gap-1' }, [ - p.label && span(p.label), + ui._select = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [ + ui._if(() => props.label, span(props.label)), select({ - ...p, - class: parseClass('select select-bordered', p.$class || p.class), - onchange: (e) => p.$value?.(e.target.value) - }, (p.options || []).map(o => - $.html('option', { value: o.value, selected: o.value === p.$value?.() }, o.label)) + ...props, + class: combineClasses('select select-bordered', props.$class || props.class), + onchange: (event) => props.$value?.(event.target.value) + }, ui._for(() => props.options || [], option => + $.html('option', { value: option.value, selected: option.value === props.$value?.() }, option.label)) ) ]); /** - * Checkbox component. + * Checkbox component with reactive value binding + * @param {Object} props - Checkbox properties + * @returns {HTMLElement} Checkbox wrapper element */ - ui._checkbox = (p) => label({ class: 'label cursor-pointer justify-start gap-3' }, [ - $.html('input', { type: 'checkbox', ...p, class: parseClass('checkbox', p.$class || p.class), $checked: p.$value }), - p.label && span({ class: 'label-text' }, p.label) + ui._checkbox = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [ + $.html('input', { type: 'checkbox', ...props, class: combineClasses('checkbox', props.$class || props.class), $checked: props.$value }), + ui._if(() => props.label, span({ class: 'label-text' }, props.label)) ]); /** - * Radio button component. + * Radio button component with reactive value binding and group support + * @param {Object} props - Radio properties + * @returns {HTMLElement} Radio wrapper element */ - ui._radio = (p) => label({ class: 'label cursor-pointer justify-start gap-3' }, [ + ui._radio = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [ $.html('input', { - type: 'radio', ...p, - class: parseClass('radio', p.$class || p.class), - $checked: () => p.$value?.() === p.value, - onclick: () => p.$value?.(p.value) + type: 'radio', ...props, + class: combineClasses('radio', props.$class || props.class), + $checked: () => props.$value?.() === props.value, + onclick: () => props.$value?.(props.value) }), - p.label && span({ class: 'label-text' }, p.label) + ui._if(() => props.label, span({ class: 'label-text' }, props.label)) ]); /** - * Range slider component. + * Range slider component with reactive value binding + * @param {Object} props - Range properties + * @returns {HTMLElement} Range wrapper element */ - ui._range = (p) => div({ class: 'flex flex-col gap-2' }, [ - p.label && span({ class: 'label-text' }, p.label), - $.html('input', { type: 'range', ...p, class: parseClass('range', p.$class || p.class), $value: p.$value }) + ui._range = (props) => div({ class: 'flex flex-col gap-2' }, [ + ui._if(() => props.label, span({ class: 'label-text' }, props.label)), + $.html('input', { type: 'range', ...props, class: combineClasses('range', props.$class || props.class), $value: props.$value }) ]); /** - * Modal dialog component. - * @param {Object} p - Modal properties. - * @param {function} p.$open - Reactive signal (boolean) to control visibility. - * @param {any} c - Modal body content. + * Modal dialog component with open state control + * @param {Object} props - Modal properties + * @param {*} children - Modal content + * @returns {Function} Function that renders modal when open condition is true */ - ui._modal = (p, c) => () => p.$open() ? dialog({ class: 'modal modal-open' }, [ - div({ class: 'modal-box' }, [ - p.title && h3({ class: 'text-lg font-bold mb-4' }, p.title), - c, - div({ class: 'modal-action' }, ui._button({ onclick: () => p.$open(false) }, "Close")) - ]), - form({ method: 'dialog', class: 'modal-backdrop', onclick: () => p.$open(false) }, button("close")) - ]) : null; + ui._modal = (props, children) => ui._if(props.$open, + dialog({ class: 'modal modal-open' }, [ + div({ class: 'modal-box' }, [ + ui._if(() => props.title, h3({ class: 'text-lg font-bold mb-4' }, props.title)), + children, + div({ class: 'modal-action' }, ui._button({ onclick: () => props.$open(false) }, translate('close'))) + ]), + form({ method: 'dialog', class: 'modal-backdrop', onclick: () => props.$open(false) }, button(translate('close'))) + ]) + ); - /** - * Dropdown menu component. + /** + * Generic Dropdown component for menus, pickers (color/date), or custom lists + * @param {Object} props - Dropdown properties + * @param {string|HTMLElement} props.label - Trigger element content (button text/icon) + * @param {string|Function} [props.$class] - daisyUI classes (e.g., 'dropdown-end', 'dropdown-hover') + * @param {boolean} [props.isAction] - If true, adds 'dropdown-open' or similar for programmatic control + * @param {*} children - Dropdown content (ul/li for menus, or custom picker/input components) + * @returns {HTMLElement} Dropdown container with keyboard focus support */ - ui._dropdown = (p, c) => div({ ...p, class: parseClass('dropdown', p.$class || p.class) }, [ - div({ tabindex: 0, role: 'button', class: 'btn m-1' }, p.label), - div({ tabindex: 0, class: 'dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52' }, c) + ui._dropdown = (props, children) => div({ + ...props, + class: combineClasses('dropdown', props.$class || props.class) + }, [ + div({ tabindex: 0, role: 'button', class: 'btn m-1' }, props.label), + div({ + tabindex: 0, + class: 'dropdown-content z-[50] p-2 shadow bg-base-100 rounded-box min-w-max' + }, children) ]); /** - * Accordion/Collapse component. + * Accordion component with radio/checkbox toggle support + * @param {Object} props - Accordion properties + * @param {*} children - Accordion content + * @returns {HTMLElement} Accordion container */ - ui._accordion = (p, c) => div({ class: 'collapse collapse-arrow bg-base-200 mb-2' }, [ - $.html('input', { type: p.name ? 'radio' : 'checkbox', name: p.name, checked: p.open }), - div({ class: 'collapse-title text-xl font-medium' }, p.title), - div({ class: 'collapse-content' }, c) + ui._accordion = (props, children) => div({ class: 'collapse collapse-arrow bg-base-200 mb-2' }, [ + $.html('input', { type: props.name ? 'radio' : 'checkbox', name: props.name, checked: props.open }), + div({ class: 'collapse-title text-xl font-medium' }, props.title), + div({ class: 'collapse-content' }, children) ]); /** - * Tabs navigation component. - * @param {Object} p - Tab properties. - * @param {Array<{label: string, active: boolean|function, onclick: function}>} p.items - Tab items. - */ - ui._tabs = (p) => div({ role: 'tablist', class: parseClass('tabs tabs-lifted', p.$class || p.class) }, - (p.items || []).map(it => a({ + * Tabs component with navigation and reactive content slots + * @param {Object} props - Tabs properties + * @param {Array} props.items - Array of tab objects { label, active: Signal/Fn, onclick: Fn, content: HTMLElement/Fn } + * @param {string|Function} [props.$class] - Optional extra classes for the tab container + * @returns {HTMLElement} Tabs container with navigation and content area + */ + ui._tabs = (props) => div({ class: 'flex flex-col gap-4 w-full' }, [ + div({ + role: 'tablist', + class: combineClasses('tabs tabs-lifted', props.$class || props.class) + }, ui._for(() => props.items || [], tabItem => a({ role: 'tab', - class: () => `tab ${(typeof it.active === 'function' ? it.active() : it.active) ? 'tab-active' : ''}`, - onclick: it.onclick - }, it.label)) - ); + class: () => `tab ${(typeof tabItem.active === 'function' ? tabItem.active() : tabItem.active) ? 'tab-active' : ''}`, + onclick: tabItem.onclick + }, tabItem.label)) + ), + div({ class: 'tab-content-area' }, () => { + const activeItem = (props.items || []).find(it => + typeof it.active === 'function' ? it.active() : it.active + ); + return activeItem ? (typeof activeItem.content === 'function' ? activeItem.content() : activeItem.content) : null; + }) + ]); /** - * Badge component. + * Badge component for status indicators + * @param {Object} props - Badge properties + * @param {*} children - Badge content + * @returns {HTMLElement} Badge element */ - ui._badge = (p, c) => span({ ...p, class: parseClass('badge', p.$class || p.class) }, c); + ui._badge = (props, children) => span({ ...props, class: combineClasses('badge', props.$class || props.class) }, children); /** - * Tooltip wrapper. + * Tooltip component that shows help text on hover + * @param {Object} props - Tooltip properties + * @param {*} children - Tooltip trigger content + * @returns {HTMLElement} Tooltip container */ - ui._tooltip = (p, c) => div({ ...p, class: parseClass('tooltip', p.$class || p.class), 'data-tip': p.tip }, c); + ui._tooltip = (props, children) => div({ ...props, class: combineClasses('tooltip', props.$class || props.class), 'data-tip': props.tip }, children); /** - * Navbar component. + * Navigation bar component + * @param {Object} props - Navbar properties + * @param {*} children - Navbar content + * @returns {HTMLElement} Navbar container */ - ui._navbar = (p, c) => div({ ...p, class: parseClass('navbar bg-base-100 shadow-sm px-4', p.$class || p.class) }, c); + ui._navbar = (props, children) => div({ ...props, class: combineClasses('navbar bg-base-100 shadow-sm px-4', props.$class || props.class) }, children); + + /** + * Menu component with support for icons, active states, and nested sub-menus + * @param {Object} props - Menu properties + * @param {Array<{label: string, icon: HTMLElement, active: Signal/Fn, onclick: Fn, children: Array}>} props.items - Menu items + * @param {string|Function} [props.$class] - daisyUI classes (e.g., 'menu-horizontal', 'w-56') + * @returns {HTMLElement} List element with support for multi-level nesting + */ + ui._menu = (props) => { + const renderItems = (items) => ui._for(() => items || [], it => li({}, [ + it.children ? [ + details({ open: it.open }, [ + summary({}, [ + it.icon && span({ class: 'mr-2' }, it.icon), + it.label + ]), + ul({}, renderItems(it.children)) + ]) + ] : + a({ + class: () => (typeof it.active === 'function' ? it.active() : it.active) ? 'active' : '', + onclick: it.onclick + }, [ + it.icon && span({ class: 'mr-2' }, it.icon), + it.label + ]) + ])); + + return ul({ + ...props, + class: combineClasses('menu bg-base-200 rounded-box', props.$class || props.class) + }, renderItems(props.items)); + }; /** - * Vertical Menu component. + * Drawer/sidebar component that slides in from the side + * @param {Object} props - Drawer properties + * @returns {HTMLElement} Drawer container */ - ui._menu = (p) => ul({ ...p, class: parseClass('menu bg-base-200 rounded-box', p.$class || p.class) }, - (p.items || []).map(it => li({}, a({ - class: () => (typeof it.active === 'function' ? it.active() : it.active) ? 'active' : '', - onclick: it.onclick - }, [it.icon && span({ class: 'mr-2' }, it.icon), it.label]))) - ); - - /** - * Sidebar Drawer component. - */ - ui._drawer = (p) => div({ class: 'drawer' }, [ - $.html('input', { id: p.id, type: 'checkbox', class: 'drawer-toggle', $checked: p.$open }), - div({ class: 'drawer-content' }, p.content), + ui._drawer = (props) => div({ class: 'drawer' }, [ + $.html('input', { id: props.id, type: 'checkbox', class: 'drawer-toggle', $checked: props.$open }), + div({ class: 'drawer-content' }, props.content), div({ class: 'drawer-side' }, [ - label({ for: p.id, class: 'drawer-overlay', onclick: () => p.$open?.(false) }), - div({ class: 'min-h-full bg-base-200 w-80' }, p.side) + label({ for: props.id, class: 'drawer-overlay', onclick: () => props.$open?.(false) }), + div({ class: 'min-h-full bg-base-200 w-80' }, props.side) ]) ]); /** - * Fieldset wrapper with legend. + * Form fieldset component with legend support + * @param {Object} props - Fieldset properties + * @param {*} children - Fieldset content + * @returns {HTMLElement} Fieldset container */ - ui._fieldset = (p, c) => fieldset({ - ...p, - class: parseClass('fieldset bg-base-200 border border-base-300 p-4 rounded-lg', p.$class || p.class) + ui._fieldset = (props, children) => fieldset({ + ...props, + class: combineClasses('fieldset bg-base-200 border border-base-300 p-4 rounded-lg', props.$class || props.class) }, [ - p.legend && legend({ class: 'fieldset-legend font-bold' }, p.legend), - c + ui._if(() => props.legend, legend({ class: 'fieldset-legend font-bold' }, props.legend)), + children ]); - // Expose components globally and to the SigPro instance - Object.keys(ui).forEach(key => { - window[key] = ui[key]; - $[key] = ui[key]; - }); + /** + * Stack component for overlapping elements + * @param {Object} props - Stack properties + * @param {*} children - Stack content + * @returns {HTMLElement} Stack container + */ + ui._stack = (props, children) => div({ ...props, class: combineClasses('stack', props.$class || props.class) }, children); + + /** + * Statistics card component for displaying metrics + * @param {Object} props - Stat properties + * @returns {HTMLElement} Stat card element + */ + ui._stat = (props) => div({ class: 'stat' }, [ + props.icon && div({ class: 'stat-figure text-secondary' }, props.icon), + props.label && div({ class: 'stat-title' }, props.label), + div({ class: 'stat-value' }, typeof props.$value === 'function' ? props.$value : props.value), + props.desc && div({ class: 'stat-desc' }, props.desc) + ]); + + /** + * Toggle switch component that swaps between two states + * @param {Object} props - Swap properties + * @returns {HTMLElement} Swap container + */ + ui._swap = (props) => label({ class: 'swap' }, [ + $.html('input', { + type: 'checkbox', + $checked: props.$value, + onchange: (event) => props.$value?.(event.target.checked) + }), + div({ class: 'swap-on' }, props.on), + div({ class: 'swap-off' }, props.off) + ]); + + let toastContainer = null; + + /** + * Toast notification component that auto-dismisses after a duration + * @param {string} message - The message to display + * @param {string} type - Alert type (e.g., "alert-success", "alert-error") + * @param {number} duration - Time in milliseconds before auto-dismiss + */ + ui._toast = (message, type = "alert-success", duration = 3500) => { + if (!toastContainer || !toastContainer.isConnected) { + toastContainer = div({ class: "fixed top-0 right-0 z-[9999] p-4 flex flex-col gap-3 pointer-events-none items-end w-full max-w-sm" }); + document.body.appendChild(toastContainer); + } + + const closeToast = (toastElement) => { + if (!toastElement || toastElement._closing) return; + toastElement._closing = true; + toastElement.style.transform = "translateX(100%)"; + toastElement.style.opacity = "0"; + setTimeout(() => { + toastElement.style.maxHeight = "0px"; + toastElement.style.marginBottom = "-0.75rem"; + toastElement.style.padding = "0px"; + }, 150); + toastElement.addEventListener("transitionend", () => { + toastElement.remove(); + if (toastContainer && !toastContainer.hasChildNodes()) { + toastContainer.remove(); + toastContainer = null; + } + }); + }; + + const toastElement = div({ class: "card bg-base-100 shadow-xl border border-base-200 w-full overflow-hidden transition-all duration-500 transform translate-x-full opacity-0 pointer-events-auto", style: "max-height: 200px;" }, [ + div({ class: "card-body p-1" }, [ + div({ role: "alert", class: `alert ${type} alert-soft border-none p-3 flex items-center justify-between gap-4` }, [ + span({ class: "font-medium text-sm" }, message), + button({ class: "btn btn-ghost btn-xs btn-circle", onclick: (event) => closeToast(event.target.closest(".card")) }, [ + span({ class: "icon-[lucide--x] w-4 h-4" }) + ]) + ]) + ]) + ]); + + toastContainer.appendChild(toastElement); + requestAnimationFrame(() => requestAnimationFrame(() => toastElement.classList.remove("translate-x-full", "opacity-0"))); + setTimeout(() => closeToast(toastElement), duration); + }; + + /** + * Confirmation dialog component with cancel and confirm actions + * @param {string} title - Dialog title + * @param {string} message - Dialog message + * @param {Function} onConfirm - Callback function when confirm is clicked + */ + ui._confirm = (title, message, onConfirm) => { + const isOpen = $(true); + const root = div(); + document.body.appendChild(root); + $.mount(root, () => ui._modal({ $open: isOpen, title }, [ + p({ class: 'py-4' }, message), + div({ class: 'modal-action gap-2' }, [ + ui._button({ class: 'btn-ghost', onclick: () => isOpen(false) }, translate('cancel')), + ui._button({ class: 'btn-primary', onclick: () => { onConfirm(); isOpen(false); } }, translate('confirm')) + ]) + ])); + $(() => { if (!isOpen()) setTimeout(() => root.remove(), 400); }); + }; + + ui._t = translate; + Object.keys(ui).forEach(key => { window[key] = ui[key]; $[key] = ui[key]; }); }; \ No newline at end of file diff --git a/sigpro/sigpro.js b/sigpro/sigpro.js index 4ebadd3..ef35c28 100644 --- a/sigpro/sigpro.js +++ b/sigpro/sigpro.js @@ -3,80 +3,142 @@ */ (() => { let activeEffect = null; + let currentOwner = null; const effectQueue = new Set(); - let isFlushScheduled = false; - let flushCount = 0; + let isFlushing = false; + const MOUNTED_NODES = new WeakMap(); - const flushQueue = () => { - isFlushScheduled = false; - flushCount++; - - if (flushCount > 100) { + const flush = () => { + if (isFlushing) return; + isFlushing = true; + while (effectQueue.size > 0) { + const sorted = Array.from(effectQueue).sort((a, b) => (a.depth || 0) - (b.depth || 0)); effectQueue.clear(); - throw new Error("SigPro: Bucle infinito detectado"); + for (const eff of sorted) if (!eff._deleted) eff(); } - - const effects = Array.from(effectQueue); - effectQueue.clear(); - effects.forEach(fn => fn()); - - queueMicrotask(() => flushCount = 0); + isFlushing = false; }; - const scheduleFlush = (s) => { - effectQueue.add(s); - if (!isFlushScheduled) { - isFlushScheduled = true; - queueMicrotask(flushQueue); + const track = subs => { + if (activeEffect && !activeEffect._deleted) { + subs.add(activeEffect); + activeEffect._deps.add(subs); } }; + const trigger = (subs) => { + for (const eff of subs) { + if (eff === activeEffect || eff._deleted) continue; + if (eff._isComputed) { + eff.markDirty(); + if (eff._subs) trigger(eff._subs); + } else { + effectQueue.add(eff); + } + } + if (!isFlushing) queueMicrotask(flush); + }; + + const isObj = v => v && typeof v === 'object' && !(v instanceof Node); + const PROXIES = new WeakMap(); + const RAW_SUBS = new WeakMap(); + + const getPropSubs = (target, prop) => { + let props = RAW_SUBS.get(target); + if (!props) RAW_SUBS.set(target, (props = new Map())); + let subs = props.get(prop); + if (!subs) props.set(prop, (subs = new Set())); + return subs; + }; + const $ = (initial, key) => { - const subs = new Set(); - - if (initial?.constructor === Object && !key) { - const store = {}; - for (let k in initial) store[k] = $(initial[k]); - return store; + if (isObj(initial) && !key && typeof initial !== 'function') { + if (PROXIES.has(initial)) return PROXIES.get(initial); + const proxy = new Proxy(initial, { + get(t, p, r) { + track(getPropSubs(t, p)); + const val = Reflect.get(t, p, r); + return isObj(val) ? $(val) : val; + }, + set(t, p, v, r) { + const old = Reflect.get(t, p, r); + if (Object.is(old, v)) return true; + const res = Reflect.set(t, p, v, r); + trigger(getPropSubs(t, p)); + if (Array.isArray(t) && p !== 'length') trigger(getPropSubs(t, 'length')); + return res; + }, + deleteProperty(t, p) { + const res = Reflect.deleteProperty(t, p); + trigger(getPropSubs(t, p)); + return res; + } + }); + PROXIES.set(initial, proxy); + return proxy; } if (typeof initial === 'function') { - let cached, running = false; - const cleanups = new Set(); + const subs = new Set(); + let cached, dirty = true; - const runner = () => { - if (runner.el && !runner.el.isConnected) return; - if (running) return; - - cleanups.forEach(fn => fn()); - cleanups.clear(); + const effect = () => { + if (effect._deleted) return; + effect._cleanups.forEach(c => c()); + effect._cleanups.clear(); + effect._deps.forEach(s => s.delete(effect)); + effect._deps.clear(); const prev = activeEffect; - activeEffect = runner; - activeEffect.onCleanup = (fn) => cleanups.add(fn); - - running = true; + activeEffect = effect; try { - const next = initial(); - if (!Object.is(cached, next)) { - cached = next; - subs.forEach(scheduleFlush); + let maxD = 0; + effect._deps.forEach(s => { if (s._d > maxD) maxD = s._d; }); + effect.depth = maxD + 1; + subs._d = effect.depth; + + const val = initial(); + if (!Object.is(cached, val) || dirty) { + cached = val; + dirty = false; + trigger(subs); } } finally { activeEffect = prev; - running = false; } }; - runner(); + + effect._isComputed = true; + effect._deps = new Set(); + effect._cleanups = new Set(); + effect._subs = subs; + effect.markDirty = () => dirty = true; + effect.stop = () => { + effect._deleted = true; + effectQueue.delete(effect); + effect._cleanups.forEach(c => c()); + effect._deps.forEach(s => s.delete(effect)); + subs.clear(); + }; + + if (currentOwner) { + currentOwner.cleanups.add(effect.stop); + effect._isComputed = false; + effect(); + return () => { }; + } + return () => { - if (activeEffect) subs.add(activeEffect); + if (dirty) effect(); + track(subs); return cached; }; } + const subs = new Set(); + subs._d = 0; if (key) { - const saved = localStorage.getItem(key); - if (saved !== null) try { initial = JSON.parse(saved); } catch (e) { } + try { const s = localStorage.getItem(key); if (s !== null) initial = JSON.parse(s); } catch (e) { } } return (...args) => { @@ -84,143 +146,147 @@ const next = typeof args[0] === 'function' ? args[0](initial) : args[0]; if (!Object.is(initial, next)) { initial = next; - if (key) localStorage.setItem(key, JSON.stringify(initial)); - subs.forEach(scheduleFlush); + if (key) try { localStorage.setItem(key, JSON.stringify(initial)); } catch (e) { } + trigger(subs); } } - if (activeEffect) { - subs.add(activeEffect); - if (activeEffect.onCleanup) activeEffect.onCleanup(() => subs.delete(activeEffect)); - } + track(subs); return initial; }; }; + const sweep = node => { + if (node._cleanups) { node._cleanups.forEach(f => f()); node._cleanups.clear(); } + node.childNodes?.forEach(sweep); + }; + + const createRuntime = fn => { + const cleanups = new Set(); + const prev = currentOwner; + currentOwner = { cleanups }; + const container = $.html('div', { style: 'display:contents' }); + try { + const res = fn({ onCleanup: f => cleanups.add(f) }); + const process = n => { + if (!n) return; + if (n._isRuntime) { cleanups.add(n.destroy); container.appendChild(n.container); } + else if (Array.isArray(n)) n.forEach(process); + else container.appendChild(n instanceof Node ? n : document.createTextNode(String(n))); + }; + process(res); + } finally { currentOwner = prev; } + return { + _isRuntime: true, + container, + destroy: () => { + cleanups.forEach(f => f()); + sweep(container); + container.remove(); + } + }; + }; + $.html = (tag, props = {}, content = []) => { - const el = document.createElement(tag); - if (props instanceof Node || Array.isArray(props) || typeof props !== 'object') { + if (props instanceof Node || Array.isArray(props) || typeof props !== "object") { content = props; props = {}; } + const el = document.createElement(tag); + el._cleanups = new Set(); - for (let [key, val] of Object.entries(props)) { - if (key.startsWith('on')) { - const [rawName, ...mods] = key.toLowerCase().slice(2).split('.'); - const handler = (e) => { - if (mods.includes('prevent')) e.preventDefault(); - if (mods.includes('stop')) e.stopPropagation(); - - if (mods.some(m => m.startsWith('debounce'))) { - const ms = mods.find(m => m.startsWith('debounce')).split(':')[1] || 300; - clearTimeout(val._timer); - val._timer = setTimeout(() => val(e), ms); - } else { - val(e); - } - }; - el.addEventListener(rawName, handler, { once: mods.includes('once') }); - } - else if (key.startsWith('$')) { - const attr = key.slice(1); - const attrEff = () => { - const v = typeof val === 'function' ? val() : val; - if (attr === 'value' || attr === 'checked') el[attr] = v; - else if (typeof v === 'boolean') el.toggleAttribute(attr, v); - else if (v == null) el.removeAttribute(attr); - else el.setAttribute(attr, v); - }; - attrEff.el = el; $(attrEff); - - if ((attr === 'value' || attr === 'checked') && typeof val === 'function') { - el.addEventListener(attr === 'checked' ? 'change' : 'input', e => - val(attr === 'checked' ? e.target.checked : e.target.value) - ); + for (let [k, v] of Object.entries(props)) { + if (k.startsWith('on')) { + const name = k.slice(2).toLowerCase().split('.')[0]; + const mods = k.slice(2).toLowerCase().split('.').slice(1); + const handler = e => { if (mods.includes('prevent')) e.preventDefault(); if (mods.includes('stop')) e.stopPropagation(); v(e); }; + el.addEventListener(name, handler, { once: mods.includes('once') }); + el._cleanups.add(() => el.removeEventListener(name, handler)); + } else if (k.startsWith('$')) { + const attr = k.slice(1); + const stopAttr = $(() => { + const val = typeof v === 'function' ? v() : v; + if (attr === 'value' || attr === 'checked') el[attr] = val; + else if (typeof val === 'boolean') el.toggleAttribute(attr, val); + else val == null ? el.removeAttribute(attr) : el.setAttribute(attr, val); + }); + el._cleanups.add(stopAttr); + if ((attr === 'value' || attr === 'checked') && typeof v === 'function') { + const evt = attr === 'checked' ? 'change' : 'input'; + const h = e => v(e.target[attr]); + el.addEventListener(evt, h); + el._cleanups.add(() => el.removeEventListener(evt, h)); } - } else el.setAttribute(key, val); + } else el.setAttribute(k, v); } - const append = (c) => { - if (Array.isArray(c)) return c.forEach(append); - if (typeof c === 'function') { - let nodes = [document.createTextNode('')]; - const contentEff = () => { + const append = c => { + if (Array.isArray(c)) c.forEach(append); + else if (typeof c === 'function') { + const marker = document.createTextNode(''); + el.appendChild(marker); + let nodes = [marker]; + const stopList = $(() => { const res = c(); - const nextNodes = (Array.isArray(res) ? res : [res]).map(i => - i instanceof Node ? i : document.createTextNode(i ?? '') - ); - if (nextNodes.length === 0) nextNodes.push(document.createTextNode('')); - - if (nodes[0].parentNode) { - const parent = nodes[0].parentNode; - nextNodes.forEach(n => parent.insertBefore(n, nodes[0])); - nodes.forEach(n => n.remove()); - nodes = nextNodes; + const next = (Array.isArray(res) ? res : [res]).map(i => i?.container || (i instanceof Node ? i : document.createTextNode(i ?? ''))); + if (marker.parentNode) { + next.forEach(n => marker.parentNode.insertBefore(n, marker)); + nodes.forEach(n => { if (n !== marker) { sweep(n); n.remove(); } }); + nodes = [...next, marker]; } - }; - contentEff.el = nodes[0]; - nodes.forEach(n => el.appendChild(n)); - $(contentEff); - return; - } - el.appendChild(c instanceof Node ? c : document.createTextNode(c ?? '')); + }); + el._cleanups.add(stopList); + } else el.appendChild(c instanceof Node ? c : document.createTextNode(c ?? '')); }; - append(content); return el; }; - const tags = ['div', 'span', 'p', 'h1', 'h2', 'ul', 'li', 'button', 'input', 'label', 'form', 'section', 'a', 'img']; + const tags = ['div', 'span', 'p', 'h1', 'h2', 'h3', 'ul', 'li', 'button', 'input', 'label', 'form', 'section', 'a', 'img', 'nav', 'hr']; + window.$ = new Proxy($, { get: (t, p) => t[p] || (tags.includes(p) ? (pr, c) => t.html(p, pr, c) : undefined) }); tags.forEach(t => window[t] = (p, c) => $.html(t, p, c)); - $.router = (routes) => { - const sPath = $(window.location.hash.replace(/^#/, "") || "/"); + $.router = routes => { + const sPath = $(window.location.hash.replace(/^#/, '') || '/'); + const handler = () => sPath(window.location.hash.replace(/^#/, '') || '/'); + window.addEventListener('hashchange', handler); + const outlet = $.html('div', { class: 'router-outlet' }); + let current = null; - window.addEventListener("hashchange", () => sPath(window.location.hash.replace(/^#/, "") || "/")); - - const container = div({ class: "router-outlet" }); - - const routeEff = () => { - const cur = sPath(); - const cP = cur.split('/').filter(Boolean); + if (currentOwner) currentOwner.cleanups.add(() => { + window.removeEventListener('hashchange', handler); + if (current) current.destroy(); + }); + $(() => { + const path = sPath(), parts = path.split('/').filter(Boolean); const route = routes.find(r => { - const rP = r.path.split('/').filter(Boolean); - return rP.length === cP.length && rP.every((p, i) => p.startsWith(':') || p === cP[i]); - }) || routes.find(r => r.path === "*"); - - if (!route) return container.replaceChildren(h1("404 - Not Found")); + const rp = r.path.split('/').filter(Boolean); + return rp.length === parts.length && rp.every((p, i) => p.startsWith(':') || p === parts[i]); + }) || routes.find(r => r.path === '*'); + if (current) current.destroy(); + if (!route) return outlet.replaceChildren($.html('h1', '404')); const params = {}; - route.path.split('/').filter(Boolean).forEach((p, i) => { - if (p.startsWith(':')) params[p.slice(1)] = cP[i]; - }); - - const res = typeof route.component === 'function' ? route.component(params) : route.component; - - if (res instanceof Promise) { - const loader = span("Cargando..."); - container.replaceChildren(loader); - res.then(c => container.replaceChildren(c instanceof Node ? c : document.createTextNode(c))); - } else { - container.replaceChildren(res instanceof Node ? res : document.createTextNode(res)); - } - }; - - routeEff.el = container; - $(routeEff); - - return container; + route.path.split('/').filter(Boolean).forEach((p, i) => { if (p.startsWith(':')) params[p.slice(1)] = parts[i]; }); + current = createRuntime(() => route.component(params)); + outlet.replaceChildren(current.container); + }); + return outlet; }; - $.router.go = (path) => { - const target = path.startsWith('/') ? path : `/${path}`; - window.location.hash = target; - }; + $.router.go = p => window.location.hash = p.replace(/^#?\/?/, '#/'); - $.mount = (node, target = 'body') => { + $.mount = (component, target) => { const el = typeof target === 'string' ? document.querySelector(target) : target; - if (el) { el.innerHTML = ''; el.appendChild(typeof node === 'function' ? node() : node); } + if (!el) return; + + if (MOUNTED_NODES.has(el)) { + MOUNTED_NODES.get(el).destroy(); + } + + const instance = createRuntime(typeof component === 'function' ? component : () => component); + el.replaceChildren(instance.container); + MOUNTED_NODES.set(el, instance); + return instance; }; - window.$ = $; -})(); -export const { $ } = window; \ No newline at end of file +})(); \ No newline at end of file