Upload
This commit is contained in:
856
plugins/ui.js
856
plugins/ui.js
@@ -29,318 +29,419 @@ export const UI = ($, defaultLang = 'es') => {
|
|||||||
// --- UTILITY FUNCTIONS ---
|
// --- UTILITY FUNCTIONS ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conditional rendering component
|
* Normalized conditional rendering.
|
||||||
* @param {Function} condition - Signal function that returns boolean
|
* @param {Function} condition - Signal returning boolean.
|
||||||
* @param {*} thenValue - Content to render when condition is true
|
* @param {*} thenValue - Content if true.
|
||||||
* @param {*} otherwiseValue - Content to render when condition is false
|
* @param {*} otherwiseValue - Content if false.
|
||||||
* @returns {Function} Function that returns appropriate content based on condition
|
* @returns {Function} Normalized accessor.
|
||||||
*/
|
*/
|
||||||
ui._if = (condition, thenValue, otherwiseValue = null) => {
|
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
|
* FOR (List Rendering): Efficient keyed reconciliation with movement optimization.
|
||||||
* @param {Function} sourceSignal - Signal function that returns an array of items
|
* @param {Function} source - Signal function returning an array of items.
|
||||||
* @param {Function} renderCallback - Callback that renders each item (item, index) => DOM element
|
* @param {Function} render - (item, index) => HTMLElement.
|
||||||
* @returns {HTMLElement} Container element that holds the rendered list
|
* @param {Function} keyFn - (item, index) => string|number. Required.
|
||||||
|
* @returns {HTMLElement} Container with fragment-like behavior and automatic item cleanup.
|
||||||
*/
|
*/
|
||||||
ui._for = (sourceSignal, renderCallback) => {
|
ui._for = (source, render, keyFn) => {
|
||||||
const itemCache = new Map();
|
if (typeof keyFn !== 'function') throw new Error('SigPro UI: _for requires a keyFn.');
|
||||||
const markerNode = document.createTextNode('');
|
|
||||||
const container = $.html('div', { style: 'display:contents' }, [markerNode]);
|
const marker = document.createTextNode('');
|
||||||
|
const container = $.html('div', { style: 'display:contents' }, [marker]);
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
$(() => {
|
$(() => {
|
||||||
const items = sourceSignal() || [];
|
const items = source() || [];
|
||||||
const newCache = new Map();
|
const newKeys = new Set();
|
||||||
const parent = markerNode.parentNode;
|
|
||||||
if (!parent) return;
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
if (itemCache.has(item)) {
|
const key = keyFn(item, index);
|
||||||
const cached = itemCache.get(item);
|
newKeys.add(key);
|
||||||
newCache.set(item, cached);
|
|
||||||
parent.insertBefore(cached.element, markerNode);
|
if (cache.has(key)) {
|
||||||
itemCache.delete(item);
|
const runtime = cache.get(key);
|
||||||
|
container.insertBefore(runtime.container, marker);
|
||||||
} else {
|
} else {
|
||||||
const element = $.html('div', { style: 'display:contents' }, [renderCallback(item, index)]);
|
const runtime = $.createRuntime(() => {
|
||||||
newCache.set(item, {
|
return $.html('div', { style: 'display:contents' }, [render(item, index)]);
|
||||||
element,
|
|
||||||
cleanup: () => {
|
|
||||||
if (element._cleanups) element._cleanups.forEach(cleanupFn => cleanupFn());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
parent.insertBefore(element, markerNode);
|
cache.set(key, runtime);
|
||||||
|
container.insertBefore(runtime.container, marker);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
itemCache.forEach(cached => {
|
cache.forEach((runtime, key) => {
|
||||||
if (cached.cleanup) cached.cleanup();
|
if (!newKeys.has(key)) {
|
||||||
cached.element.remove();
|
runtime.destroy();
|
||||||
|
runtime.container.remove();
|
||||||
|
cache.delete(key);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
itemCache.clear();
|
|
||||||
newCache.forEach((value, key) => itemCache.set(key, value));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- INTERNAL HELPERS ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combines base CSS classes with conditional or static extra classes
|
* REQ (Request): Reactive fetch handler with auto-abort on re-executions or component destruction.
|
||||||
* @param {string} baseClasses - Base class names to always include
|
* @param {string|Function} url - Target URL or Signal function returning a URL.
|
||||||
* @param {string|Function} extraClasses - Additional classes or function returning classes
|
* @param {Object} [payload] - Data to send in the body.
|
||||||
* @returns {string|Function} Combined classes or function that returns combined classes
|
* @param {Object} [options] - Fetch options including method, headers, and transform function.
|
||||||
*/
|
* @returns {{data: Function, loading: Function, error: Function, success: Function, reload: Function}}
|
||||||
const combineClasses = (baseClasses, extraClasses) => {
|
*/
|
||||||
if (typeof extraClasses === 'function') return () => `${baseClasses} ${extraClasses() || ''}`;
|
ui._req = (url, payload = null, options = {}) => {
|
||||||
return `${baseClasses} ${extraClasses || ''}`;
|
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;
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
// --- INTERNAL HELPERS ---
|
||||||
* 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
|
* Combines base CSS classes with conditional or static extra classes
|
||||||
* @param {string} [props.badgeClass] - daisyUI classes for the badge (e.g., 'badge-primary')
|
* @param {string} baseClasses - Base class names to always include
|
||||||
* @param {HTMLElement|string} [props.icon] - Icon element or string to place before children
|
* @param {string|Function} extraClasses - Additional classes or function returning classes
|
||||||
* @param {string} [props.tooltip] - Text to display in the daisyUI tooltip
|
* @returns {string|Function} Combined classes or function that returns combined classes
|
||||||
* @param {Function} [props.$loading] - SigPro Signal: if true, shows spinner and disables button
|
*/
|
||||||
* @param {Function} [props.$disabled] - SigPro Signal: if true, disables the button
|
const combineClasses = (base, extra) => {
|
||||||
* @param {string|Function} [props.$class] - Additional reactive or static CSS classes for the button
|
if (typeof extra === 'function') {
|
||||||
* @param {Function} [props.onclick] - Click event handler
|
return () => `${base} ${extra() || ''}`.trim();
|
||||||
* @param {*} children - Button text or inner content
|
}
|
||||||
* @returns {HTMLElement} Button element (wrapped in indicator/tooltip containers if props are present)
|
return `${base} ${extra || ''}`.trim();
|
||||||
*/
|
};
|
||||||
ui._button = (props, children) => {
|
|
||||||
const btnEl = button({
|
// --- UI COMPONENTS ---
|
||||||
...props,
|
|
||||||
badge: undefined,
|
/**
|
||||||
badgeClass: undefined,
|
* Button component with loading state, icon, indicator badge, and tooltip support
|
||||||
tooltip: undefined,
|
* @param {Object} props - Button properties
|
||||||
class: combineClasses('btn', props.$class || props.class),
|
* @param {string|number|Function} [props.badge] - Content for the indicator badge
|
||||||
$disabled: () => props.$disabled?.() || props.$loading?.()
|
* @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
|
||||||
ui._if(() => props.$loading?.(), span({ class: 'loading loading-spinner' })),
|
* @param {string} [props.tooltip] - Text to display in the daisyUI tooltip
|
||||||
props.icon && span({ class: 'mr-1' }, props.icon),
|
* @param {Function} [props.$loading] - SigPro Signal: if true, shows spinner and disables button
|
||||||
children
|
* @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) {
|
return out;
|
||||||
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);
|
* 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
|
||||||
return out;
|
* @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
|
||||||
* Input component with label, tooltip, error state, and search placeholder support
|
* @param {string|Function} [props.$class] - Additional reactive or static CSS classes for the input
|
||||||
* @param {Object} props - Input properties
|
* @param {string} [props.placeholder] - Standard HTML placeholder attribute
|
||||||
* @param {string} [props.label] - Text for the input label
|
* @param {string} [props.type] - Standard HTML input type (text, password, email, etc.)
|
||||||
* @param {string} [props.tip] - Contextual help text displayed in a tooltip next to the label
|
* @param {Function} [props.oninput] - Event handler for input changes (handled automatically if $value is a signal)
|
||||||
* @param {boolean} [props.isSearch] - If true, uses internationalized "search" placeholder if none provided
|
* @returns {HTMLElement} Label wrapper containing the label, tooltip, input, and error message
|
||||||
* @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
|
|
||||||
*/
|
*/
|
||||||
ui._select = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [
|
ui._input = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [
|
||||||
ui._if(() => props.label, span(props.label)),
|
ui._if(() => props.label, div({ class: 'flex items-center gap-2' }, [
|
||||||
select({
|
span(props.label),
|
||||||
...props,
|
ui._if(() => props.tip, div({ class: 'tooltip tooltip-right', 'data-tip': props.tip },
|
||||||
class: combineClasses('select select-bordered', props.$class || props.class),
|
span({ class: 'badge badge-ghost badge-xs' }, '?')))
|
||||||
onchange: (event) => props.$value?.(event.target.value)
|
])),
|
||||||
}, ui._for(() => props.options || [], option =>
|
$.html('input', {
|
||||||
$.html('option', { value: option.value, selected: option.value === props.$value?.() }, option.label))
|
...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
|
* SELECT: Dropdown component with native SigPro $value binding and keyed options.
|
||||||
* @param {Object} props - Checkbox properties
|
* @param {Object} props - Select properties including $value signal and options array.
|
||||||
* @returns {HTMLElement} Checkbox wrapper element
|
* @returns {HTMLElement} Styled select element within a label wrapper.
|
||||||
*/
|
*/
|
||||||
ui._checkbox = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [
|
ui._select = (props) => label({ class: 'fieldset-label flex flex-col gap-1' }, [
|
||||||
$.html('input', { type: 'checkbox', ...props, class: combineClasses('checkbox', props.$class || props.class), $checked: props.$value }),
|
ui._if(() => props.label, span(props.label)),
|
||||||
ui._if(() => props.label, span({ class: 'label-text' }, 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
|
* Checkbox component with reactive value binding
|
||||||
* @param {Object} props - Radio properties
|
* @param {Object} props - Checkbox properties
|
||||||
* @returns {HTMLElement} Radio wrapper element
|
* @returns {HTMLElement} Checkbox wrapper element
|
||||||
*/
|
*/
|
||||||
ui._radio = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [
|
ui._checkbox = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [
|
||||||
$.html('input', {
|
$.html('input', { type: 'checkbox', ...props, class: combineClasses('checkbox', props.$class || props.class), $checked: props.$value }),
|
||||||
type: 'radio', ...props,
|
ui._if(() => props.label, span({ class: 'label-text' }, props.label))
|
||||||
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))
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Range slider component with reactive value binding
|
* Radio button component with reactive value binding and group support
|
||||||
* @param {Object} props - Range properties
|
* @param {Object} props - Radio properties
|
||||||
* @returns {HTMLElement} Range wrapper element
|
* @returns {HTMLElement} Radio wrapper element
|
||||||
*/
|
*/
|
||||||
ui._range = (props) => div({ class: 'flex flex-col gap-2' }, [
|
ui._radio = (props) => label({ class: 'label cursor-pointer justify-start gap-3' }, [
|
||||||
ui._if(() => props.label, span({ class: 'label-text' }, props.label)),
|
$.html('input', {
|
||||||
$.html('input', { type: 'range', ...props, class: combineClasses('range', props.$class || props.class), $value: props.$value })
|
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
|
* Range slider component with reactive value binding
|
||||||
* @param {Object} props - Modal properties
|
* @param {Object} props - Range properties
|
||||||
* @param {*} children - Modal content
|
* @returns {HTMLElement} Range wrapper element
|
||||||
* @returns {Function} Function that renders modal when open condition is true
|
*/
|
||||||
*/
|
ui._range = (props) => div({ class: 'flex flex-col gap-2' }, [
|
||||||
ui._modal = (props, children) => ui._if(props.$open,
|
ui._if(() => props.label, span({ class: 'label-text' }, props.label)),
|
||||||
dialog({ class: 'modal modal-open' }, [
|
$.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' }, [
|
div({ class: 'modal-box' }, [
|
||||||
ui._if(() => props.title, h3({ class: 'text-lg font-bold mb-4' }, props.title)),
|
ui._if(() => props.title, h3({ class: 'text-lg font-bold mb-4' }, props.title)),
|
||||||
children,
|
children,
|
||||||
div({ class: 'modal-action' }, ui._button({ onclick: () => props.$open(false) }, translate('close')))
|
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')))
|
form({ method: 'dialog', class: 'modal-backdrop', onclick: () => props.$open(false) }, button(translate('close')))
|
||||||
])
|
]));
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
const stopWatcher = $(() => {
|
||||||
* Generic Dropdown component for menus, pickers (color/date), or custom lists
|
if (!props.$open()) {
|
||||||
* @param {Object} props - Dropdown properties
|
activeRuntime?.destroy();
|
||||||
* @param {string|HTMLElement} props.label - Trigger element content (button text/icon)
|
stopWatcher();
|
||||||
* @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)
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
return activeRuntime.container;
|
||||||
* 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 component with navigation and reactive content slots
|
* Generic Dropdown component for menus, pickers (color/date), or custom lists
|
||||||
* @param {Object} props - Tabs properties
|
* @param {Object} props - Dropdown properties
|
||||||
* @param {Array} props.items - Array of tab objects { label, active: Signal/Fn, onclick: Fn, content: HTMLElement/Fn }
|
* @param {string|HTMLElement} props.label - Trigger element content (button text/icon)
|
||||||
* @param {string|Function} [props.$class] - Optional extra classes for the tab container
|
* @param {string|Function} [props.$class] - daisyUI classes (e.g., 'dropdown-end', 'dropdown-hover')
|
||||||
* @returns {HTMLElement} Tabs container with navigation and content area
|
* @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)
|
||||||
ui._tabs = (props) => div({ class: 'flex flex-col gap-4 w-full' }, [
|
* @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({
|
div({
|
||||||
role: 'tablist',
|
role: 'tablist',
|
||||||
class: combineClasses('tabs tabs-lifted', props.$class || props.class)
|
class: combineClasses('tabs tabs-lifted', props.$class || props.class)
|
||||||
}, ui._for(() => props.items || [], tabItem => a({
|
}, ui._for(itemsSignal, tabItem => a({
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
class: () => `tab ${(typeof tabItem.active === 'function' ? tabItem.active() : tabItem.active) ? 'tab-active' : ''}`,
|
class: () => `tab ${(typeof tabItem.active === 'function' ? tabItem.active() : tabItem.active) ? 'tab-active' : ''}`,
|
||||||
onclick: tabItem.onclick
|
onclick: tabItem.onclick
|
||||||
}, tabItem.label))
|
}, tabItem.label), t => t.label)),
|
||||||
),
|
|
||||||
div({ class: 'tab-content-area' }, () => {
|
div({ class: 'tab-content-area' }, () => {
|
||||||
const activeItem = (props.items || []).find(it =>
|
const activeItem = itemsSignal().find(it =>
|
||||||
typeof it.active === 'function' ? it.active() : it.active
|
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
|
* Badge component for status indicators
|
||||||
* @param {Object} props - Badge properties
|
* @param {Object} props - Badge properties
|
||||||
* @param {*} children - Badge content
|
* @param {*} children - Badge content
|
||||||
* @returns {HTMLElement} Badge element
|
* @returns {HTMLElement} Badge element
|
||||||
*/
|
*/
|
||||||
ui._badge = (props, children) => span({ ...props, class: combineClasses('badge', props.$class || props.class) }, children);
|
ui._badge = (props, children) => span({ ...props, class: combineClasses('badge', props.$class || props.class) }, children);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tooltip component that shows help text on hover
|
* Tooltip component that shows help text on hover
|
||||||
* @param {Object} props - Tooltip properties
|
* @param {Object} props - Tooltip properties
|
||||||
* @param {*} children - Tooltip trigger content
|
* @param {*} children - Tooltip trigger content
|
||||||
* @returns {HTMLElement} Tooltip container
|
* @returns {HTMLElement} Tooltip container
|
||||||
*/
|
*/
|
||||||
ui._tooltip = (props, children) => div({ ...props, class: combineClasses('tooltip', props.$class || props.class), 'data-tip': props.tip }, children);
|
ui._tooltip = (props, children) => div({ ...props, class: combineClasses('tooltip', props.$class || props.class), 'data-tip': props.tip }, children);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation bar component
|
* Navigation bar component
|
||||||
* @param {Object} props - Navbar properties
|
* @param {Object} props - Navbar properties
|
||||||
* @param {*} children - Navbar content
|
* @param {*} children - Navbar content
|
||||||
* @returns {HTMLElement} Navbar container
|
* @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);
|
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
|
* Menu component with support for icons, active states, and nested sub-menus
|
||||||
* @param {Object} props - Menu properties
|
* @param {Object} props - Menu properties
|
||||||
* @param {Array<{label: string, icon: HTMLElement, active: Signal/Fn, onclick: Fn, children: Array}>} props.items - Menu items
|
* @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')
|
* @param {string|Function} [props.$class] - daisyUI classes (e.g., 'menu-horizontal', 'w-56')
|
||||||
* @returns {HTMLElement} List element with support for multi-level nesting
|
* @returns {HTMLElement} List element with support for multi-level nesting
|
||||||
*/
|
*/
|
||||||
ui._menu = (props) => {
|
ui._menu = (props) => {
|
||||||
const renderItems = (items) => ui._for(() => items || [], it => li({}, [
|
const renderItems = (items) => ui._for(() => items || [], it => li({}, [
|
||||||
it.children ? [
|
it.children ? [
|
||||||
details({ open: it.open }, [
|
details({ open: it.open }, [
|
||||||
summary({}, [
|
summary({}, [
|
||||||
it.icon && span({ class: 'mr-2' }, it.icon),
|
it.icon && span({ class: 'mr-2' }, it.icon),
|
||||||
it.label
|
it.label
|
||||||
]),
|
]),
|
||||||
ul({}, renderItems(it.children))
|
ul({}, renderItems(it.children))
|
||||||
])
|
])
|
||||||
] :
|
] :
|
||||||
a({
|
a({
|
||||||
class: () => (typeof it.active === 'function' ? it.active() : it.active) ? 'active' : '',
|
class: () => (typeof it.active === 'function' ? it.active() : it.active) ? 'active' : '',
|
||||||
onclick: it.onclick
|
onclick: it.onclick
|
||||||
@@ -348,146 +449,135 @@ export const UI = ($, defaultLang = 'es') => {
|
|||||||
it.icon && span({ class: 'mr-2' }, it.icon),
|
it.icon && span({ class: 'mr-2' }, it.icon),
|
||||||
it.label
|
it.label
|
||||||
])
|
])
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return ul({
|
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({
|
|
||||||
...props,
|
...props,
|
||||||
class: combineClasses('fieldset bg-base-200 border border-base-300 p-4 rounded-lg', props.$class || props.class)
|
class: combineClasses('menu bg-base-200 rounded-box', props.$class || props.class)
|
||||||
}, [
|
}, renderItems(props.items));
|
||||||
ui._if(() => props.legend, legend({ class: 'fieldset-legend font-bold' }, props.legend)),
|
};
|
||||||
children
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stack component for overlapping elements
|
* Drawer/sidebar component that slides in from the side
|
||||||
* @param {Object} props - Stack properties
|
* @param {Object} props - Drawer properties
|
||||||
* @param {*} children - Stack content
|
* @returns {HTMLElement} Drawer container
|
||||||
* @returns {HTMLElement} Stack container
|
*/
|
||||||
*/
|
ui._drawer = (props) => div({ class: 'drawer' }, [
|
||||||
ui._stack = (props, children) => div({ ...props, class: combineClasses('stack', props.$class || props.class) }, children);
|
$.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
|
* Form fieldset component with legend support
|
||||||
* @param {Object} props - Stat properties
|
* @param {Object} props - Fieldset properties
|
||||||
* @returns {HTMLElement} Stat card element
|
* @param {*} children - Fieldset content
|
||||||
*/
|
* @returns {HTMLElement} Fieldset container
|
||||||
ui._stat = (props) => div({ class: 'stat' }, [
|
*/
|
||||||
props.icon && div({ class: 'stat-figure text-secondary' }, props.icon),
|
ui._fieldset = (props, children) => fieldset({
|
||||||
props.label && div({ class: 'stat-title' }, props.label),
|
...props,
|
||||||
div({ class: 'stat-value' }, typeof props.$value === 'function' ? props.$value : props.value),
|
class: combineClasses('fieldset bg-base-200 border border-base-300 p-4 rounded-lg', props.$class || props.class)
|
||||||
props.desc && div({ class: 'stat-desc' }, props.desc)
|
}, [
|
||||||
]);
|
ui._if(() => props.legend, legend({ class: 'fieldset-legend font-bold' }, props.legend)),
|
||||||
|
children
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle switch component that swaps between two states
|
* Stack component for overlapping elements
|
||||||
* @param {Object} props - Swap properties
|
* @param {Object} props - Stack properties
|
||||||
* @returns {HTMLElement} Swap container
|
* @param {*} children - Stack content
|
||||||
*/
|
* @returns {HTMLElement} Stack container
|
||||||
ui._swap = (props) => label({ class: 'swap' }, [
|
*/
|
||||||
$.html('input', {
|
ui._stack = (props, children) => div({ ...props, class: combineClasses('stack', props.$class || props.class) }, children);
|
||||||
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;
|
/**
|
||||||
|
* 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
|
* Toggle switch component that swaps between two states
|
||||||
* @param {string} message - The message to display
|
* @param {Object} props - Swap properties
|
||||||
* @param {string} type - Alert type (e.g., "alert-success", "alert-error")
|
* @returns {HTMLElement} Swap container
|
||||||
* @param {number} duration - Time in milliseconds before auto-dismiss
|
*/
|
||||||
*/
|
ui._swap = (props) => label({ class: 'swap' }, [
|
||||||
ui._toast = (message, type = "alert-success", duration = 3500) => {
|
$.html('input', {
|
||||||
if (!toastContainer || !toastContainer.isConnected) {
|
type: 'checkbox',
|
||||||
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" });
|
$checked: props.$value,
|
||||||
document.body.appendChild(toastContainer);
|
onchange: (event) => props.$value?.(event.target.checked)
|
||||||
}
|
}),
|
||||||
|
div({ class: 'swap-on' }, props.on),
|
||||||
|
div({ class: 'swap-off' }, props.off)
|
||||||
|
]);
|
||||||
|
|
||||||
const closeToast = (toastElement) => {
|
let toastContainer = null;
|
||||||
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" }, [
|
* TOAST: Notification system with direct reference management and runtime cleanup.
|
||||||
div({ role: "alert", class: `alert ${type} alert-soft border-none p-3 flex items-center justify-between gap-4` }, [
|
* @param {string} message - Text to display.
|
||||||
span({ class: "font-medium text-sm" }, message),
|
* @param {string} [type] - daisyUI alert class.
|
||||||
button({ class: "btn btn-ghost btn-xs btn-circle", onclick: (event) => closeToast(event.target.closest(".card")) }, [
|
* @param {number} [duration] - Expiry time in ms.
|
||||||
span({ class: "icon-[lucide--x] w-4 h-4" })
|
*/
|
||||||
])
|
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);
|
const remove = () => {
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => toastElement.classList.remove("translate-x-full", "opacity-0")));
|
el.classList.add('translate-x-full', 'opacity-0');
|
||||||
setTimeout(() => closeToast(toastElement), duration);
|
setTimeout(() => {
|
||||||
};
|
runtime.destroy();
|
||||||
|
el.remove();
|
||||||
|
if (!container.hasChildNodes()) container.remove();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
setTimeout(remove, duration);
|
||||||
* Confirmation dialog component with cancel and confirm actions
|
return el;
|
||||||
* @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;
|
const toastEl = runtime.container.firstChild;
|
||||||
Object.keys(ui).forEach(key => { window[key] = ui[key]; $[key] = ui[key]; });
|
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]; });
|
||||||
};
|
};
|
||||||
355
sigpro/llms.txt
Normal file
355
sigpro/llms.txt
Normal file
@@ -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 `<input>`, `<textarea>`, etc.
|
||||||
|
- `$checked` – two‑way binding for checkboxes/radios.
|
||||||
|
|
||||||
|
When `$value` or `$checked` is a function (signal), the element’s `value`/`checked` property is updated, and the element’s `input`/`change` event will update the signal.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const name = $('');
|
||||||
|
input({ $value: name }); // auto‑updates name when user types
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handlers (prefixed with `on`)
|
||||||
|
|
||||||
|
Events are attached with `onEventName`. The event name is case‑insensitive and can be followed by modifiers separated by dots.
|
||||||
|
|
||||||
|
```js
|
||||||
|
button({ onclick: () => console.log('clicked') }, 'Click');
|
||||||
|
```
|
||||||
|
|
||||||
|
Modifiers:
|
||||||
|
- `.prevent` – calls `e.preventDefault()`
|
||||||
|
- `.stop` – calls `e.stopPropagation()`
|
||||||
|
- `.once` – removes listener after first call
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
button({ onClick.prevent: handleSubmit }, 'Submit');
|
||||||
|
div({ onClick.stop: handleDivClick }, 'Click');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Children
|
||||||
|
|
||||||
|
Children can be:
|
||||||
|
|
||||||
|
- **String** – inserted as text node.
|
||||||
|
- **DOM Node** – inserted directly.
|
||||||
|
- **Array** – each item is processed recursively.
|
||||||
|
- **Function** – treated as reactive content. The function is called every time its dependencies change, and the result is rendered in place.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const count = $(0);
|
||||||
|
div([
|
||||||
|
'Static text',
|
||||||
|
() => `Count: ${count()}`, // updates automatically
|
||||||
|
button({ onclick: () => count(count()+1) }, '+')
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
When a function child returns an array, each element is inserted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
### `$.router(routes)`
|
||||||
|
|
||||||
|
Creates a router outlet that displays the component matching the current hash (`#/`). Returns a DOM element (a `div` with class `router-outlet`) that can be inserted anywhere.
|
||||||
|
|
||||||
|
`routes` is an array of objects with `path` and `component` properties.
|
||||||
|
|
||||||
|
- `path`: string like `/`, `/users`, `/users/:id`. Dynamic parameters are prefixed with `:`.
|
||||||
|
- `component`: a function that receives an object of params and returns a DOM node / runtime.
|
||||||
|
|
||||||
|
A `*` path serves as a 404 catch‑all.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: () => Home() },
|
||||||
|
{ path: '/users/:id', component: (params) => UserDetail(params.id) },
|
||||||
|
{ path: '*', component: () => NotFound() }
|
||||||
|
];
|
||||||
|
|
||||||
|
const outlet = $.router(routes);
|
||||||
|
document.body.appendChild(outlet);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `$.router.go(path)`
|
||||||
|
|
||||||
|
Programmatically navigates to a new hash path.
|
||||||
|
|
||||||
|
```js
|
||||||
|
$.router.go('/users/123');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mounting
|
||||||
|
|
||||||
|
### `$.mount(component, target)`
|
||||||
|
|
||||||
|
Mounts a component into a DOM element. The component can be a function (called to produce a runtime) or a static DOM node.
|
||||||
|
|
||||||
|
- `target`: a CSS selector string or a DOM element.
|
||||||
|
- Returns the runtime instance (with a `destroy` method).
|
||||||
|
|
||||||
|
If the target already has a mounted SigPro instance, it is automatically destroyed before the new one is mounted.
|
||||||
|
|
||||||
|
```js
|
||||||
|
$.mount(() => div('Hello'), '#app');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Functions
|
||||||
|
|
||||||
|
A component function receives an object with `onCleanup` which can be used to register cleanup callbacks.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const MyComponent = ({ onCleanup }) => {
|
||||||
|
const timer = setInterval(() => {}, 1000);
|
||||||
|
onCleanup(() => clearInterval(timer));
|
||||||
|
return div('Component');
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Components can return:
|
||||||
|
- a DOM node
|
||||||
|
- an array of nodes
|
||||||
|
- a runtime object (like one returned from another `$.mount` call, but typically you return a node)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Management
|
||||||
|
|
||||||
|
SigPro automatically cleans up resources to prevent leaks:
|
||||||
|
|
||||||
|
- **WeakMaps** for proxies and mounted nodes allow garbage collection.
|
||||||
|
- Every effect, event listener, or reactive attribute created through SigPro is stored in `_cleanups` on the element and removed when the element is swept.
|
||||||
|
- The `sweep` function recursively cleans all child nodes and their attached effects.
|
||||||
|
- When a component is unmounted (by calling `destroy()` or via router), all its effects and event listeners are removed.
|
||||||
|
|
||||||
|
Effects that return a cleanup function have that function called before the next run and on unmount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Counter with localStorage persistence
|
||||||
|
|
||||||
|
```js
|
||||||
|
const Counter = () => {
|
||||||
|
const count = $(0, 'counter'); // persistent signal
|
||||||
|
return div([
|
||||||
|
span(() => `Count: ${count()}`),
|
||||||
|
button({ onclick: () => count(count() + 1) }, '+'),
|
||||||
|
button({ onclick: () => count(count() - 1) }, '-')
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
$.mount(Counter, '#app');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form with two‑way binding
|
||||||
|
|
||||||
|
```js
|
||||||
|
const Form = () => {
|
||||||
|
const name = $('');
|
||||||
|
const email = $('');
|
||||||
|
return form({ onsubmit: (e) => e.preventDefault() }, [
|
||||||
|
label('Name:'),
|
||||||
|
input({ $value: name, placeholder: 'Your name' }),
|
||||||
|
label('Email:'),
|
||||||
|
input({ $value: email, type: 'email' }),
|
||||||
|
p(() => `Hello, ${name() || 'stranger'}`)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router with parameters
|
||||||
|
|
||||||
|
```js
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: () => div('Home') },
|
||||||
|
{ path: '/user/:id', component: (p) => div(`User ${p.id}`) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const App = () => div([
|
||||||
|
nav([
|
||||||
|
a({ href: '#/' }, 'Home'),
|
||||||
|
a({ href: '#/user/42' }, 'User 42')
|
||||||
|
]),
|
||||||
|
$.router(routes)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$.mount(App, '#app');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effect with cleanup
|
||||||
|
|
||||||
|
```js
|
||||||
|
const Clock = () => {
|
||||||
|
const time = $(new Date().toLocaleTimeString());
|
||||||
|
$(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
time(new Date().toLocaleTimeString());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
return span(() => time());
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for AI
|
||||||
|
|
||||||
|
- SigPro is entirely self‑contained. No external libraries are needed.
|
||||||
|
- All reactivity is based on signals and proxies.
|
||||||
|
- To update the DOM, just change a signal – the UI updates automatically.
|
||||||
|
- Use `$` for state, `$.html` (or tag shortcuts) for DOM.
|
||||||
|
- The router uses hash navigation only; it does not use the History API.
|
||||||
|
- There is no built‑in `_if` or `_for`; those are part of optional UI plugins. For conditional rendering, use a function child that returns different content based on a signal.
|
||||||
|
- For lists, a common pattern is to create a function child that maps over an array and returns an array of elements. However, the core does not provide keyed diffing; that is left to the user or plugin.
|
||||||
|
|
||||||
|
For any further details, refer to the source code of `$.html`, `$.router`, and the reactive internals. The API is stable and minimal.
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user