diff --git a/plugins/ui.js b/plugins/ui.js index ad4b744..fd73b25 100644 --- a/plugins/ui.js +++ b/plugins/ui.js @@ -29,318 +29,419 @@ export const UI = ($, defaultLang = 'es') => { // --- 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 + * Normalized conditional rendering. + * @param {Function} condition - Signal returning boolean. + * @param {*} thenValue - Content if true. + * @param {*} otherwiseValue - Content if false. + * @returns {Function} Normalized accessor. */ ui._if = (condition, thenValue, otherwiseValue = null) => { - return () => condition() ? thenValue : otherwiseValue; + return () => { + const isTrue = condition(); + const result = isTrue ? thenValue : otherwiseValue; + if (typeof result === 'function' && !(result instanceof HTMLElement)) { + return result(); + } + return result; + }; }; /** - * 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 + * FOR (List Rendering): Efficient keyed reconciliation with movement optimization. + * @param {Function} source - Signal function returning an array of items. + * @param {Function} render - (item, index) => HTMLElement. + * @param {Function} keyFn - (item, index) => string|number. Required. + * @returns {HTMLElement} Container with fragment-like behavior and automatic item cleanup. */ - ui._for = (sourceSignal, renderCallback) => { - const itemCache = new Map(); - const markerNode = document.createTextNode(''); - const container = $.html('div', { style: 'display:contents' }, [markerNode]); + ui._for = (source, render, keyFn) => { + if (typeof keyFn !== 'function') throw new Error('SigPro UI: _for requires a keyFn.'); + + const marker = document.createTextNode(''); + const container = $.html('div', { style: 'display:contents' }, [marker]); + const cache = new Map(); $(() => { - const items = sourceSignal() || []; - const newCache = new Map(); - const parent = markerNode.parentNode; - if (!parent) return; + const items = source() || []; + const newKeys = new Set(); 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); + const key = keyFn(item, index); + newKeys.add(key); + + if (cache.has(key)) { + const runtime = cache.get(key); + container.insertBefore(runtime.container, marker); } else { - const element = $.html('div', { style: 'display:contents' }, [renderCallback(item, index)]); - newCache.set(item, { - element, - cleanup: () => { - if (element._cleanups) element._cleanups.forEach(cleanupFn => cleanupFn()); - } + const runtime = $.createRuntime(() => { + return $.html('div', { style: 'display:contents' }, [render(item, index)]); }); - parent.insertBefore(element, markerNode); + cache.set(key, runtime); + container.insertBefore(runtime.container, marker); } }); - itemCache.forEach(cached => { - if (cached.cleanup) cached.cleanup(); - cached.element.remove(); + cache.forEach((runtime, key) => { + if (!newKeys.has(key)) { + runtime.destroy(); + runtime.container.remove(); + cache.delete(key); + } }); - - itemCache.clear(); - newCache.forEach((value, key) => itemCache.set(key, value)); }); return container; }; - // --- INTERNAL HELPERS --- - /** - * 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 - */ - const combineClasses = (baseClasses, extraClasses) => { - if (typeof extraClasses === 'function') return () => `${baseClasses} ${extraClasses() || ''}`; - return `${baseClasses} ${extraClasses || ''}`; + * REQ (Request): Reactive fetch handler with auto-abort on re-executions or component destruction. + * @param {string|Function} url - Target URL or Signal function returning a URL. + * @param {Object} [payload] - Data to send in the body. + * @param {Object} [options] - Fetch options including method, headers, and transform function. + * @returns {{data: Function, loading: Function, error: Function, success: Function, reload: Function}} + */ + ui._req = (url, payload = null, options = {}) => { + const data = $(null), loading = $(false), error = $(null), success = $(false); + let abortController = null; + + const execute = async (customPayload = null) => { + const targetUrl = typeof url === 'function' ? url() : url; + if (!targetUrl) return; + + if (abortController) abortController.abort(); + abortController = new AbortController(); + + loading(true); error(null); success(false); + try { + const bodyData = customPayload || payload; + const res = await fetch(targetUrl, { + method: options.method || (bodyData ? 'POST' : 'GET'), + headers: { 'Content-Type': 'application/json', ...options.headers }, + body: bodyData ? JSON.stringify(bodyData) : null, + signal: abortController.signal, + ...options + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + let json = await res.json(); + if (typeof options.transform === 'function') json = options.transform(json); + + data(json); + success(true); + } catch (err) { + if (err.name !== 'AbortError') error(err.message); + } finally { + loading(false); + } + }; + + $(() => { + execute(); + return () => abortController?.abort(); + }); + + return { data, loading, error, success, reload: (p) => execute(p) }; }; - // --- UI COMPONENTS --- +/** + * RES (Resource/Response): UI handler for a Request object. + * @param {Object} reqObj - The object returned by _req. + * @param {Function} renderFn - (data) => HTMLElement. Executed only on success. + * @returns {HTMLElement} A reactive container handling loading, error, and success states. + */ +ui._res = (reqObj, renderFn) => div({ class: 'res-container' }, [ + ui._if(reqObj.loading, + div({ class: 'flex justify-center p-4' }, span({ class: 'loading loading-dots text-primary' })) + ), + ui._if(reqObj.error, () => + div({ role: 'alert', class: 'alert alert-error' }, [ + span(reqObj.error()), + ui._button({ class: 'btn-xs btn-ghost border-current', onclick: () => reqObj.reload() }, 'Retry') + ]) + ), + ui._if(reqObj.success, () => { + const current = reqObj.data(); + return current !== null ? renderFn(current) : null; + }) +]); - /** - * 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 +// --- INTERNAL HELPERS --- + +/** + * 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 + */ +const combineClasses = (base, extra) => { + if (typeof extra === 'function') { + return () => `${base} ${extra() || ''}`.trim(); + } + return `${base} ${extra || ''}`.trim(); +}; + +// --- 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 ]); + } - let out = btnEl; + if (props.tooltip) { + out = div({ class: 'tooltip', 'data-tip': props.tooltip }, out); + } - if (props.badge) { - out = div({ class: 'indicator' }, [ - span({ class: combineClasses(`indicator-item badge ${props.badgeClass || 'badge-secondary'}`) }, props.badge), - out - ]); - } + return 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', { - ...props, - placeholder: props.placeholder || (props.isSearch ? translate('search') : ''), - class: combineClasses('input input-bordered w-full', props.$class || props.class), - $value: props.$value - }), - ui._if(() => props.$error?.(), span({ class: 'text-error text-xs' }, () => props.$error())) - ]); - - /** - * Select dropdown component with reactive options and value binding - * @param {Object} props - Select properties - * @returns {HTMLElement} Select wrapper element +/** + * 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._select = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [ - ui._if(() => props.label, span(props.label)), - select({ - ...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)) - ) - ]); +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', { + ...props, + placeholder: props.placeholder || (props.isSearch ? translate('search') : ''), + class: combineClasses('input input-bordered w-full', props.$class || props.class), + $value: props.$value + }), + ui._if(() => props.$error?.(), span({ class: 'text-error text-xs' }, () => props.$error())) +]); - /** - * Checkbox component with reactive value binding - * @param {Object} props - Checkbox properties - * @returns {HTMLElement} Checkbox wrapper element - */ - 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)) - ]); +/** + * SELECT: Dropdown component with native SigPro $value binding and keyed options. + * @param {Object} props - Select properties including $value signal and options array. + * @returns {HTMLElement} Styled select element within a label wrapper. + */ +ui._select = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [ + ui._if(() => props.label, span(props.label)), + $.html('select', { + ...props, + class: combineClasses('select select-bordered', props.$class || props.class), + $value: props.$value, + onchange: (e) => props.$value?.(e.target.value) + }, ui._for(() => props.options || [], opt => + $.html('option', { value: opt.value }, opt.label), + opt => opt.value + )) +]); - /** - * Radio button component with reactive value binding and group support - * @param {Object} props - Radio properties - * @returns {HTMLElement} Radio wrapper element - */ - ui._radio = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [ - $.html('input', { - type: 'radio', ...props, - class: combineClasses('radio', props.$class || props.class), - $checked: () => props.$value?.() === props.value, - onclick: () => props.$value?.(props.value) - }), - ui._if(() => props.label, span({ class: 'label-text' }, props.label)) - ]); +/** + * Checkbox component with reactive value binding + * @param {Object} props - Checkbox properties + * @returns {HTMLElement} Checkbox wrapper element + */ +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)) +]); - /** - * Range slider component with reactive value binding - * @param {Object} props - Range properties - * @returns {HTMLElement} Range wrapper element - */ - 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 }) - ]); +/** + * Radio button component with reactive value binding and group support + * @param {Object} props - Radio properties + * @returns {HTMLElement} Radio wrapper element + */ +ui._radio = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [ + $.html('input', { + type: 'radio', ...props, + class: combineClasses('radio', props.$class || props.class), + $checked: () => props.$value?.() === props.value, + onclick: () => props.$value?.(props.value) + }), + ui._if(() => props.label, span({ class: 'label-text' }, props.label)) +]); - /** - * 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 = (props, children) => ui._if(props.$open, - dialog({ class: 'modal modal-open' }, [ +/** + * Range slider component with reactive value binding + * @param {Object} props - Range properties + * @returns {HTMLElement} Range wrapper element + */ +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 with explicit runtime destruction via watcher. + * @param {Object} props - Modal properties including $open signal and title. + * @param {*} children - Inner modal content. + * @returns {Function} Conditional modal renderer. + */ +ui._modal = (props, children) => { + let activeRuntime = null; + + return ui._if(props.$open, () => { + activeRuntime = $.createRuntime(() => 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'))) - ]) - ); + ])); - /** - * 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 = (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) - ]); + const stopWatcher = $(() => { + if (!props.$open()) { + activeRuntime?.destroy(); + stopWatcher(); + } + }); - /** - * Accordion component with radio/checkbox toggle support - * @param {Object} props - Accordion properties - * @param {*} children - Accordion content - * @returns {HTMLElement} Accordion container - */ - 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) - ]); + return activeRuntime.container; + }); +}; - /** - * 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' }, [ +/** + * 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 = (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 component with radio/checkbox toggle support + * @param {Object} props - Accordion properties + * @param {*} children - Accordion content + * @returns {HTMLElement} Accordion container + */ +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 with reactive items and content slots. + * @param {Object} props - Tabs properties including items array or signal. + * @returns {HTMLElement} Tabs container with navigation and content area. + */ +ui._tabs = (props) => { + const itemsSignal = typeof props.items === 'function' ? props.items : () => props.items || []; + + return 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({ + }, ui._for(itemsSignal, tabItem => a({ role: 'tab', class: () => `tab ${(typeof tabItem.active === 'function' ? tabItem.active() : tabItem.active) ? 'tab-active' : ''}`, onclick: tabItem.onclick - }, tabItem.label)) - ), + }, tabItem.label), t => t.label)), + div({ class: 'tab-content-area' }, () => { - const activeItem = (props.items || []).find(it => + const activeItem = itemsSignal().find(it => typeof it.active === 'function' ? it.active() : it.active ); - return activeItem ? (typeof activeItem.content === 'function' ? activeItem.content() : activeItem.content) : null; + if (!activeItem) return null; + return typeof activeItem.content === 'function' ? activeItem.content() : activeItem.content; }) ]); +}; - /** - * Badge component for status indicators - * @param {Object} props - Badge properties - * @param {*} children - Badge content - * @returns {HTMLElement} Badge element - */ - ui._badge = (props, children) => span({ ...props, class: combineClasses('badge', props.$class || props.class) }, children); +/** + * Badge component for status indicators + * @param {Object} props - Badge properties + * @param {*} children - Badge content + * @returns {HTMLElement} Badge element + */ +ui._badge = (props, children) => span({ ...props, class: combineClasses('badge', props.$class || props.class) }, children); - /** - * Tooltip component that shows help text on hover - * @param {Object} props - Tooltip properties - * @param {*} children - Tooltip trigger content - * @returns {HTMLElement} Tooltip container - */ - ui._tooltip = (props, children) => div({ ...props, class: combineClasses('tooltip', props.$class || props.class), 'data-tip': props.tip }, children); +/** + * Tooltip component that shows help text on hover + * @param {Object} props - Tooltip properties + * @param {*} children - Tooltip trigger content + * @returns {HTMLElement} Tooltip container + */ +ui._tooltip = (props, children) => div({ ...props, class: combineClasses('tooltip', props.$class || props.class), 'data-tip': props.tip }, children); - /** - * Navigation bar component - * @param {Object} props - Navbar properties - * @param {*} children - Navbar content - * @returns {HTMLElement} Navbar container - */ - ui._navbar = (props, children) => div({ ...props, class: combineClasses('navbar bg-base-100 shadow-sm px-4', props.$class || props.class) }, children); +/** + * Navigation bar component + * @param {Object} props - Navbar properties + * @param {*} children - Navbar content + * @returns {HTMLElement} Navbar container + */ +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)) - ]) - ] : +/** + * 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 @@ -348,146 +449,135 @@ export const UI = ($, defaultLang = 'es') => { 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)); - }; - - /** - * Drawer/sidebar component that slides in from the side - * @param {Object} props - Drawer properties - * @returns {HTMLElement} Drawer container - */ - 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: props.id, class: 'drawer-overlay', onclick: () => props.$open?.(false) }), - div({ class: 'min-h-full bg-base-200 w-80' }, props.side) - ]) - ]); - - /** - * Form fieldset component with legend support - * @param {Object} props - Fieldset properties - * @param {*} children - Fieldset content - * @returns {HTMLElement} Fieldset container - */ - ui._fieldset = (props, children) => fieldset({ + return ul({ ...props, - class: combineClasses('fieldset bg-base-200 border border-base-300 p-4 rounded-lg', props.$class || props.class) - }, [ - ui._if(() => props.legend, legend({ class: 'fieldset-legend font-bold' }, props.legend)), - children - ]); + class: combineClasses('menu bg-base-200 rounded-box', props.$class || props.class) + }, renderItems(props.items)); +}; - /** - * 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); +/** + * Drawer/sidebar component that slides in from the side + * @param {Object} props - Drawer properties + * @returns {HTMLElement} Drawer container + */ +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: props.id, class: 'drawer-overlay', onclick: () => props.$open?.(false) }), + div({ class: 'min-h-full bg-base-200 w-80' }, props.side) + ]) +]); - /** - * 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) - ]); +/** + * Form fieldset component with legend support + * @param {Object} props - Fieldset properties + * @param {*} children - Fieldset content + * @returns {HTMLElement} Fieldset container + */ +ui._fieldset = (props, children) => fieldset({ + ...props, + class: combineClasses('fieldset bg-base-200 border border-base-300 p-4 rounded-lg', props.$class || props.class) +}, [ + ui._if(() => props.legend, legend({ class: 'fieldset-legend font-bold' }, props.legend)), + children +]); - /** - * 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) - ]); +/** + * 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); - let toastContainer = null; +/** + * 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) +]); - /** - * 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); - } +/** + * 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) +]); - 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; - } - }); - }; +let 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" }) - ]) - ]) - ]) +/** + * TOAST: Notification system with direct reference management and runtime cleanup. + * @param {string} message - Text to display. + * @param {string} [type] - daisyUI alert class. + * @param {number} [duration] - Expiry time in ms. + */ +ui._toast = (message, type = "alert-success", duration = 3500) => { + let container = document.getElementById('sigpro-toast-container'); + if (!container) { + container = $.html('div', { id: 'sigpro-toast-container', class: 'fixed top-0 right-0 z-[9999] p-4 flex flex-col gap-2' }); + document.body.appendChild(container); + } + + const runtime = $.createRuntime(() => { + const el = div({ class: `alert ${type} shadow-lg transition-all duration-300 translate-x-10 opacity-0` }, [ + span(message), + ui._button({ class: 'btn-xs btn-circle btn-ghost', onclick: () => remove() }, '✕') ]); - toastContainer.appendChild(toastElement); - requestAnimationFrame(() => requestAnimationFrame(() => toastElement.classList.remove("translate-x-full", "opacity-0"))); - setTimeout(() => closeToast(toastElement), duration); - }; + const remove = () => { + el.classList.add('translate-x-full', 'opacity-0'); + setTimeout(() => { + runtime.destroy(); + el.remove(); + if (!container.hasChildNodes()) container.remove(); + }, 300); + }; - /** - * 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); }); - }; + setTimeout(remove, duration); + return el; + }); - ui._t = translate; - Object.keys(ui).forEach(key => { window[key] = ui[key]; $[key] = ui[key]; }); + const toastEl = runtime.container.firstChild; + container.appendChild(runtime.container); + requestAnimationFrame(() => toastEl.classList.remove('translate-x-10', 'opacity-0')); +}; +/** + * 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/llms.txt b/sigpro/llms.txt new file mode 100644 index 0000000..6362552 --- /dev/null +++ b/sigpro/llms.txt @@ -0,0 +1,355 @@ +```txt +# SigPro Core – Complete Reference for AI + +SigPro is a reactive UI library built on signals, with no virtual DOM and no compiler. It provides fine-grained reactivity, automatic cleanup, and direct DOM manipulation. This document describes the core API as exposed by the `$` global. + +--- + +## The `$` Function + +The `$` function creates **signals**, **computed values**, **effects**, and **persistent state**. Its behavior depends on the argument type. + +### 1. Primitive Signal + +```js +const count = $(0); // create with initial value +count(); // read → 0 +count(5); // write → 5 +count(v => v + 1); // update (increment) +``` + +### 2. Reactive Object (Proxy) + +If the argument is a plain object or array, a reactive proxy is returned. All properties become reactive, including nested objects. + +```js +const user = $({ name: 'Juan', age: 30 }); +user().name; // read property → 'Juan' +user.name = 'Carlos'; // write property → triggers updates +user.age = 31; // also reactive +``` + +Nested objects are automatically wrapped in proxies when accessed. + +### 3. Computed Signal + +If the argument is a **function**, it creates a computed signal. The function is executed lazily and caches its result. Dependencies are automatically tracked. + +```js +const count = $(0); +const double = $(() => count() * 2); +double(); // → 0 +count(5); +double(); // → 10 (automatically updated) +``` + +Computed signals are read‑only. + +### 4. Effect + +If you call `$` with a function **inside a component or runtime**, it becomes an effect that runs immediately and re‑runs whenever any of its dependencies change. The effect can return a cleanup function. + +```js +$(() => { + console.log('Count changed:', count()); +}); + +$(() => { + const timer = setInterval(() => console.log('tick'), 1000); + return () => clearInterval(timer); // cleanup on each re‑run or unmount +}); +``` + +Effects are automatically stopped when the component is unmounted (see `$.mount`). + +### 5. Persistent Signal + +If you pass a second argument (a string key), the signal is synchronized with `localStorage`. The initial value is loaded from storage, and every write is saved. + +```js +const theme = $('light', 'theme-key'); +const settings = $({ volume: 50 }, 'app-settings'); +``` + +--- + +## DOM Creation + +### `$.html(tag, props, children)` + +Creates a DOM element with the given tag, properties, and children. + +```js +const el = $.html('div', { class: 'container' }, 'Hello'); +``` + +If the second argument is not an object, it is treated as children: + +```js +$.html('div', 'Hello'); // no props +$.html('div', [h1('Title'), p('Text')]); // children array +``` + +### Tag Shortcuts + +Common tags are directly available as functions: + +```js +div(props, children) +span(props, children) +p() +h1(), h2(), h3() +ul(), li() +button() +input() +label() +form() +section() +a() +img() +nav() +hr() +``` + +All tag functions call `$.html` internally. + +--- + +## Props + +Props are objects passed to `$.html` or tag functions. + +### Static Attributes + +Standard HTML attributes are set as strings or booleans. + +```js +div({ class: 'btn', id: 'submit', disabled: true }, 'Click'); +``` + +### Reactive Attributes (prefixed with `$`) + +Attributes starting with `$` are reactive. Their value can be a static value, a signal, or a function returning a value. The attribute is updated automatically when the value changes. + +```js +const isActive = $(false); +div({ $class: () => isActive() ? 'active' : 'inactive' }); +``` + +Special reactive attributes: +- `$value` – two‑way binding for ``, `