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