Next Preview Work Final

This commit is contained in:
2026-04-03 23:54:11 +02:00
parent 257107e198
commit a6705621d8
49 changed files with 1119 additions and 493 deletions

226
dist/sigpro-ui.js vendored
View File

@@ -68,7 +68,7 @@
Accordion: () => Accordion
});
// node_modules/sigpro/sigpro/index.js
// src/sigpro.js
var activeEffect = null;
var currentOwner = null;
var effectQueue = new Set;
@@ -272,6 +272,7 @@
}
const el = document.createElement(tag), _sanitize = (key, val) => (key === "src" || key === "href") && String(val).toLowerCase().includes("javascript:") ? "#" : val;
el._cleanups = new Set;
const boolAttrs = ["disabled", "checked", "required", "readonly", "selected", "multiple", "autofocus"];
for (let [key, val] of Object.entries(props)) {
if (key === "ref") {
typeof val === "function" ? val(el) : val.current = el;
@@ -294,33 +295,54 @@
} else if (isSignal) {
el._cleanups.add($watch2(() => {
const currentVal = _sanitize(key, val());
if (key === "class")
if (key === "class") {
el.className = currentVal || "";
else
} else if (boolAttrs.includes(key)) {
if (currentVal) {
el.setAttribute(key, "");
el[key] = true;
} else {
el.removeAttribute(key);
el[key] = false;
}
} else {
currentVal == null ? el.removeAttribute(key) : el.setAttribute(key, currentVal);
}
}));
} else {
el.setAttribute(key, _sanitize(key, val));
if (boolAttrs.includes(key)) {
if (val) {
el.setAttribute(key, "");
el[key] = true;
} else {
el.removeAttribute(key);
el[key] = false;
}
} else {
el.setAttribute(key, _sanitize(key, val));
}
}
}
const append = (child) => {
if (Array.isArray(child))
return child.forEach(append);
if (typeof child === "function") {
if (child instanceof Node) {
el.appendChild(child);
} else if (typeof child === "function") {
const marker = document.createTextNode("");
el.appendChild(marker);
let nodes = [];
el._cleanups.add($watch2(() => {
const result = child(), nextNodes = (Array.isArray(result) ? result : [result]).map((item) => item?._isRuntime ? item.container : item instanceof Node ? item : document.createTextNode(item ?? ""));
nodes.forEach((node) => {
sweep(node);
node.remove();
const res = child(), next = (Array.isArray(res) ? res : [res]).map((i) => i?._isRuntime ? i.container : i instanceof Node ? i : document.createTextNode(i ?? ""));
nodes.forEach((n) => {
sweep?.(n);
n.remove();
});
nextNodes.forEach((node) => marker.parentNode?.insertBefore(node, marker));
nodes = nextNodes;
next.forEach((n) => marker.parentNode?.insertBefore(n, marker));
nodes = next;
}));
} else
el.appendChild(child instanceof Node ? child : document.createTextNode(child ?? ""));
el.appendChild(document.createTextNode(child ?? ""));
};
append(content);
return el;
@@ -345,9 +367,9 @@
return container;
};
$if.not = (condition, thenVal, otherwiseVal) => $if(() => !(typeof condition === "function" ? condition() : condition), thenVal, otherwiseVal);
var $for = (source, render, keyFn) => {
var $for = (source, render, keyFn, tag = "div", props = { style: "display:contents" }) => {
const marker = document.createTextNode("");
const container = $html2("div", { style: "display:contents" }, [marker]);
const container = $html2(tag, props, [marker]);
let cache = new Map;
$watch2(() => {
const items = (typeof source === "function" ? source() : source) || [];
@@ -450,6 +472,7 @@
};
install(SigProCore);
}
// src/components/index.js
var exports_components = {};
__export(exports_components, {
@@ -617,10 +640,12 @@
placeholder,
disabled,
size,
validate,
...rest
} = props;
const isPassword = type === "password";
const visible = $(false);
const errorMsg = $(null);
const iconMap = {
text: "icon-[lucide--text]",
password: "icon-[lucide--lock]",
@@ -644,16 +669,35 @@
return "btn-lg";
return "btn-md";
};
const handleInput = (e) => {
const newValue = e.target.value;
if (validate) {
const result = validate(newValue);
errorMsg(result || null);
}
oninput?.(e);
};
const hasError = () => errorMsg() && errorMsg() !== "";
const inputClasses = () => {
let classes = `input w-full ${paddingLeft} ${paddingRight}`;
if (className)
classes += ` ${className}`;
if (hasError())
classes += " input-error";
return classes.trim();
};
const inputElement = $html2("input", {
...rest,
type: () => isPassword ? visible() ? "text" : "password" : type,
placeholder: placeholder || " ",
class: inputClasses,
value,
oninput: handleInput,
disabled: () => val(disabled),
"aria-invalid": () => hasError() ? "true" : "false"
});
return $html2("div", { class: "relative w-full" }, () => [
$html2("input", {
...rest,
type: () => isPassword ? visible() ? "text" : "password" : type,
placeholder: placeholder || " ",
class: ui("input w-full", `${paddingLeft} ${paddingRight} ${className || ""}`.trim()),
value,
oninput: (e) => oninput?.(e),
disabled: () => val(disabled)
}),
inputElement,
leftIcon ? $html2("div", {
class: "absolute left-3 inset-y-0 flex items-center pointer-events-none text-base-content/60"
}, leftIcon) : null,
@@ -664,7 +708,10 @@
e.preventDefault();
visible(!visible());
}
}, () => getPasswordIcon()) : null
}, () => getPasswordIcon()) : null,
$html2("div", {
class: "text-error text-xs mt-1 px-3 absolute -bottom-5 left-0"
}, () => hasError() ? errorMsg() : null)
]);
};
@@ -1353,7 +1400,7 @@
List: () => List
});
var List = (props) => {
const { class: className, items, header, render, keyFn = (item, index) => index, ...rest } = props;
const { class: className, items, header, render, keyFn = (item, index) => item.id ?? index, ...rest } = props;
const listItems = $for(items, (item, index) => $html2("li", { class: "list-row" }, [render(item, index)]), keyFn);
return $html2("ul", {
...rest,
@@ -1607,6 +1654,7 @@
const pinRowsClass = val(pinRows) ? "table-pin-rows" : "";
return ui("table", className, zebraClass, pinRowsClass);
};
const getInternalKeyFn = keyFn || ((item, idx) => item.id || idx);
return $html2("div", { class: "overflow-x-auto w-full bg-base-100 rounded-box border border-base-300" }, [
$html2("table", { ...rest, class: tableClass }, [
$html2("thead", {}, [
@@ -1614,25 +1662,27 @@
]),
$html2("tbody", {}, [
$for(items, (item, index) => {
const it = () => {
const currentItems = val(items);
const key = getInternalKeyFn(item, index);
return currentItems.find((u, i) => getInternalKeyFn(u, i) === key) || item;
};
return $html2("tr", { class: "hover" }, columns.map((col) => {
const cellContent = () => {
const latestItem = it();
if (col.render)
return col.render(item, index);
const value = item[col.key];
return val(value);
return col.render(latestItem, index);
return val(latestItem[col.key]);
};
return $html2("td", { class: col.class || "" }, [cellContent]);
}));
}, keyFn || ((item, idx) => item.id || idx)),
}, getInternalKeyFn),
$if(() => val(items).length === 0, () => $html2("tr", {}, [
$html2("td", { colspan: columns.length, class: "text-center p-10 opacity-50" }, [
val(empty)
])
]))
]),
$if(() => columns.some((c) => c.footer), () => $html2("tfoot", {}, [
$html2("tr", {}, columns.map((col) => $html2("th", {}, col.footer || "")))
]))
])
])
]);
};
@@ -1645,55 +1695,55 @@
var Tabs = (props) => {
const { items, class: className, ...rest } = props;
const itemsSignal = typeof items === "function" ? items : () => items || [];
const name = `tabs-${Math.random().toString(36).slice(2, 9)}`;
const getActiveIndex = () => {
const arr = itemsSignal();
const idx = arr.findIndex((it) => val(it.active) === true);
return idx === -1 ? 0 : idx;
};
const activeIndex = $(getActiveIndex);
const updateActiveIndex = () => {
const newIndex = getActiveIndex();
if (newIndex !== activeIndex())
activeIndex(newIndex);
};
$watch(() => updateActiveIndex());
return $html2("div", {
...rest,
class: ui("tabs", className || "tabs-box")
}, [
$for(itemsSignal, (it, idx) => {
const isChecked = () => activeIndex() === idx;
const getLabelText = () => {
const label = typeof it.label === "function" ? it.label() : it.label;
return typeof label === "string" ? label : `Tab ${idx + 1}`;
};
return [
$html2("input", {
type: "radio",
name,
class: "tab",
"aria-label": getLabelText(),
checked: isChecked,
disabled: () => val(it.disabled),
onchange: (e) => {
if (e.target.checked && !val(it.disabled)) {
const activeIndex = $(0);
$watch(() => {
const idx = itemsSignal().findIndex((it) => val(it.active) === true);
if (idx !== -1 && idx !== activeIndex())
activeIndex(idx);
});
return $html2("div", { ...rest, class: "w-full" }, [
$html2("div", {
role: "tablist",
class: ui("tabs", className || "tabs-box")
}, () => {
const list = itemsSignal();
return list.map((it, idx) => {
const isSelected = () => activeIndex() === idx;
const tab = $html2("button", {
role: "tab",
class: () => ui("tab", isSelected() ? "tab-active" : ""),
onclick: (e) => {
e.preventDefault();
if (!val(it.disabled)) {
if (it.onclick)
it.onclick();
if (typeof it.active === "function")
it.active(true);
activeIndex(idx);
}
}
}),
$html2("div", {
});
$watch(() => {
const content = val(it.label);
if (content instanceof Node) {
tab.replaceChildren(content);
} else {
tab.textContent = String(content);
}
});
return tab;
});
}),
$html2("div", { class: "tab-panels" }, () => {
return itemsSignal().map((it, idx) => {
const isVisible = () => activeIndex() === idx;
return $html2("div", {
role: "tabpanel",
class: "tab-content bg-base-100 border-base-300 p-6",
style: () => isChecked() ? "display: block" : "display: none"
style: () => isVisible() ? "display: block" : "display: none"
}, [
typeof it.content === "function" ? it.content() : it.content
])
];
}, (it, idx) => idx)
() => typeof it.content === "function" ? it.content() : it.content
]);
});
})
]);
};
@@ -1710,27 +1760,29 @@
warning: "icon-[lucide--alert-triangle]",
error: "icon-[lucide--alert-circle]"
};
const itemsSource = typeof items === "function" ? items : () => items || [];
return $html2("ul", {
...rest,
class: () => ui(`timeline ${val(vertical) ? "timeline-vertical" : "timeline-horizontal"} ${val(compact) ? "timeline-compact" : ""}`, className)
}, [
$for(itemsSource, (item, i) => {
}, () => {
const list = (typeof items === "function" ? items() : items) || [];
return list.map((item, i) => {
const isFirst = i === 0;
const isLast = i === itemsSource().length - 1;
const isLast = i === list.length - 1;
const itemType = item.type || "success";
const isCompleted = () => val(item.completed);
const prevCompleted = () => i > 0 && val(list[i - 1].completed);
const renderSlot = (content) => typeof content === "function" ? content() : content;
return $html2("li", { class: "flex-1" }, [
!isFirst ? $html2("hr", { class: () => item.completed ? "bg-primary" : "" }) : null,
$html2("div", { class: "timeline-start" }, () => renderSlot(item.title)),
$html2("div", { class: "timeline-middle" }, () => [
item.icon ? getIcon(item.icon) : getIcon(iconMap[itemType] || iconMap.success)
!isFirst ? $html2("hr", { class: () => prevCompleted() ? "bg-primary" : "" }) : null,
$html2("div", { class: "timeline-start" }, [() => renderSlot(item.title)]),
$html2("div", { class: "timeline-middle" }, [
() => item.icon ? getIcon(item.icon) : getIcon(iconMap[itemType] || iconMap.success)
]),
$html2("div", { class: "timeline-end timeline-box shadow-sm" }, () => renderSlot(item.detail)),
!isLast ? $html2("hr", { class: () => item.completed ? "bg-primary" : "" }) : null
$html2("div", { class: "timeline-end timeline-box shadow-sm" }, [() => renderSlot(item.detail)]),
!isLast ? $html2("hr", { class: () => isCompleted() ? "bg-primary" : "" }) : null
]);
}, (item, i) => item.id || i)
]);
});
});
};
// src/components/Toast.js

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@
</div>
<h1 class="text-7xl md:text-9xl font-black tracking-tighter mb-4 bg-clip-text text-transparent bg-linear-to-r from-secondary via-accent to-primary !text-center w-full">SigPro UI beta (W.I.P.)</h1>
<div class="text-2xl md:text-3xl font-bold text-base-content/90 mb-4 !text-center w-full">Reactive Design System for SigPro</div>
<div class="text-xl text-base-content/60 max-w-3xl mx-auto mb-10 leading-relaxed italic text-balance font-light !text-center w-full">"Atomic components for high-performance interfaces. Zero-boilerplate, pure DaisyUI v5 elegance."</div>
<div class="text-xl text-base-content/60 max-w-3xl mx-auto mb-10 leading-relaxed italic text-balance font-light !text-center w-full">"Atomic components for high-performance interfaces. Zero-boilerplate, pure reactivity."</div>
<div class="flex flex-wrap justify-center gap-4 w-full">
<a href="#/install" class="btn btn-secondary btn-lg shadow-xl shadow-secondary/20 group px-10 border-none text-secondary-content">View Components <span class="group-hover:translate-x-1 transition-transform inline-block">→</span></a>
<button onclick="window.open('https://github.com/natxocc/sigpro-ui')" class="btn btn-outline btn-lg border-base-300 hover:bg-base-300 hover:text-base-content">GitHub</button>
@@ -25,26 +25,26 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-stretch">
<div class="card bg-base-200/30 border border-base-300 hover:border-secondary/40 transition-all p-1">
<div class="card-body p-6">
<h3 class="card-title text-xl font-black text-secondary italic">TAILWIND V4</h3>
<p class="text-sm opacity-70">Built on the latest CSS engine. Lightning fast styles with zero legacy overhead.</p>
<h3 class="card-title text-xl font-black text-secondary italic">SIGPRO NATIVE</h3>
<p class="text-sm opacity-70">Direct integration with SigPro signals. No wrappers, no context, just pure atomic reactivity.</p>
</div>
</div>
<div class="card bg-base-200/30 border border-base-300 hover:border-accent/40 transition-all p-1">
<div class="card-body p-6">
<h3 class="card-title text-xl font-black text-accent italic">DAISYUI V5</h3>
<p class="text-sm opacity-70">Semantic, beautiful and accessible. Professional components out of the box.</p>
<h3 class="card-title text-xl font-black text-accent italic">ZERO CONFIG</h3>
<p class="text-sm opacity-70">Import and build immediately. Designed for developers who hate configuration files.</p>
</div>
</div>
<div class="card bg-base-200/30 border border-base-300 hover:border-primary/40 transition-all p-1">
<div class="card-body p-6">
<h3 class="card-title text-xl font-black text-primary italic">NATIVE REACTION</h3>
<p class="text-sm opacity-70">Direct integration with SigPro signals. No wrappers, no context, just speed.</p>
<h3 class="card-title text-xl font-black text-primary italic">REACTIVE BY DESIGN</h3>
<p class="text-sm opacity-70">Every component is a high-order function optimized for SigPro's fine-grained updates.</p>
</div>
</div>
<div class="card bg-base-200/30 border border-base-300 hover:border-base-content/20 transition-all p-1">
<div class="card-body p-6">
<h3 class="card-title text-xl font-black italic text-base-content">READY-TO-GO</h3>
<p class="text-sm opacity-70">Import and build. Designed for developers who hate configuration files.</p>
<p class="text-sm opacity-70">60+ atomic components. Semantic, accessible, and production-ready.</p>
</div>
</div>
</div>
@@ -54,13 +54,13 @@
SigPro-UI isn't just a library; it's a **Functional Design System**.
It eliminates the gap between your data (Signals) and your layout (DaisyUI). Each component is a high-order function optimized for the SigPro core, ensuring that your UI only updates where it matters.
It eliminates the gap between your data (Signals) and your UI components. Each component is a high-order function optimized for the SigPro core, ensuring that your interface only updates where it matters.
| Requirement | Value | Why? |
| :--- | :--- | :--- |
| **Engine** | **SigPro** | Atomic reactivity without V-DOM. |
| **Styling** | **Tailwind CSS v4** | Pure CSS performance. |
| **Components** | **daisyUI v5** | Semantic and clean layouts. |
| **Components** | **SigPro-UI** | 60+ semantic, reactive components. |
| **Styling** | **daisyUI v5** | Beautiful, accessible, themeable. |
| **Learning Curve** | **Zero** | If you know JS and HTML, you know SigPro-UI. |
### Semantic Functionalism
@@ -73,43 +73,43 @@ Modal({
title: "Precision Engineering"
}, () =>
Div({ class: "space-y-4" }, [
P("SigPro-UI leverages Tailwind v4 for instant styling."),
P("SigPro-UI provides instant reactivity out of the box."),
Button({
class: "btn-primary",
onclick: () => isVisible(false)
}, "Confirm")
])
)
````
```
-----
---
## Technical Stack Requirements
To achieve the performance promised by SigPro-UI, your environment must be equipped with:
### 1\. SigPro Core
### 1. SigPro Core
The atomic heart. SigPro-UI requires the SigPro runtime (`$`, `$watch`, `$html`, etc.) to be present in the global scope or provided as a module.
### 2\. Tailwind CSS v4 Engine
SigPro-UI uses the modern `@theme` and utility engine of Tailwind v4. It is designed to work with the ultra-fast compiler of the new generation.
### 3\. daisyUI v5
### 2. daisyUI v5
The visual DNA. All components are mapped to daisyUI v5 semantic classes, providing access to dozens of themes and accessible UI patterns without writing a single line of custom CSS.
-----
### 3. Modern Browser
SigPro-UI uses modern Web APIs and requires no polyfills for evergreen browsers.
---
<div class="bg-base-200/50 rounded-3xl p-10 my-16 border border-base-300 shadow-inner">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
<div class="lg:col-span-2">
<h2 class="text-4xl font-black mb-4 mt-0 tracking-tight italic text-secondary">Design at Runtime.</h2>
<h2 class="text-4xl font-black mb-4 mt-0 tracking-tight italic text-secondary">Reactive at Runtime.</h2>
<p class="text-xl opacity-80 leading-relaxed">
Combine the best of three worlds: <strong>SigPro</strong> for logic,
<strong>Tailwind v4</strong> for speed, and <strong>daisyUI v5</strong> for beauty.
Build interfaces that feel as fast as they look.
Combine the best of both worlds: <strong>SigPro</strong> for logic and
<strong>daisyUI v5</strong> for beauty. Build interfaces that feel as fast as they look,
with components that react instantly to your data changes.
</p>
</div>
</div>

View File

@@ -43,8 +43,3 @@
* [Menu](components/menu.md)
* [Navbar](components/navbar.md)
* [Tabs](components/tabs.md)
* **Advanced**
* [Reactivity Guide](advanced/reactivity.md)
* [i18n Guide](advanced/i18n.md)
* [Theming](advanced/theming.md)

View File

@@ -171,7 +171,7 @@ const CartDemo = () => {
),
Span({ class: 'text-lg font-bold' }, () => `Total: $${total()}`)
]),
cart().length === 0
() => cart().length === 0
? Div({ class: 'alert alert-soft text-center' }, 'Cart is empty')
: Div({ class: 'flex flex-col gap-2' }, cart().map(item =>
Div({ class: 'flex justify-between items-center p-2 bg-base-200 rounded-lg' }, [
@@ -187,6 +187,7 @@ const CartDemo = () => {
))
]);
};
$mount(CartDemo, '#demo-cart');
```
@@ -235,7 +236,7 @@ const InboxDemo = () => {
}
}, 'Mark all read')
]),
Div({ class: 'flex flex-col gap-2' }, messages().map(msg =>
Div({ class: 'flex flex-col gap-2' }, () => messages().map(msg =>
Div({
class: `p-3 rounded-lg cursor-pointer transition-all ${msg.read ? 'bg-base-200 opacity-60' : 'bg-primary/10 border-l-4 border-primary'}`,
onclick: () => markAsRead(msg.id)
@@ -247,6 +248,7 @@ const InboxDemo = () => {
))
]);
};
$mount(InboxDemo, '#demo-inbox');
```

View File

@@ -125,20 +125,22 @@ $mount(TooltipDemo, '#demo-tooltip');
```javascript
const ErrorDemo = () => {
const email = $('');
const isValid = $(true);
const validate = (value) => {
const valid = value.includes('@') && value.includes('.');
isValid(valid);
email(value);
};
return Input({
type: 'email',
value: email,
error: () => !isValid() && email() ? 'Invalid email address' : '',
oninput: (e) => validate(e.target.value)
});
return Div({ class: 'w-full max-w-md' }, [
Input({
type: 'email',
value: email,
placeholder: 'Enter your email',
icon: 'icon-[lucide--mail]',
validate: (value) => {
if (!value) return '';
if (!value.includes('@')) return 'Email must contain @';
if (!value.includes('.')) return 'Email must contain .';
return '';
},
oninput: (e) => email(e.target.value)
})
]);
};
$mount(ErrorDemo, '#demo-error');
```

View File

@@ -8,23 +8,23 @@ List component with custom item rendering, headers, and reactive data binding.
## Props
| Prop | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `items` | `Array \| Signal<Array>` | `[]` | Data array to display |
| `header` | `string \| VNode \| Signal` | `-` | Optional header content |
| `render` | `function(item, index)` | Required | Custom render function for each item |
| `keyFn` | `function(item, index)` | `(item, idx) => idx` | Unique key function for items |
| `class` | `string` | `''` | Additional CSS classes (DaisyUI + Tailwind) |
| Prop | Type | Default | Description |
| :------- | :-------------------------- | :------------------- | :------------------------------------------ |
| `items` | `Array \| Signal<Array>` | `[]` | Data array to display |
| `header` | `string \| VNode \| Signal` | `-` | Optional header content |
| `render` | `function(item, index)` | Required | Custom render function for each item |
| `keyFn` | `function(item, index)` | `(item, idx) => idx` | Unique key function for items |
| `class` | `string` | `''` | Additional CSS classes (DaisyUI + Tailwind) |
## Styling
List supports all **daisyUI List classes**:
| Category | Keywords | Description |
| :--- | :--- | :--- |
| Base | `list` | Base list styling |
| Variant | `list-row` | Row styling for list items |
| Background | `bg-base-100` | Background color |
| Category | Keywords | Description |
| :--------- | :------------ | :------------------------- |
| Base | `list` | Base list styling |
| Variant | `list-row` | Row styling for list items |
| Background | `bg-base-100` | Background color |
> For further details, check the [daisyUI List Documentation](https://daisyui.com/components/list) Full reference for CSS classes.
@@ -41,16 +41,17 @@ List supports all **daisyUI List classes**:
```javascript
const BasicDemo = () => {
const items = ['Apple', 'Banana', 'Orange', 'Grape', 'Mango'];
const items = ["Apple", "Banana", "Orange", "Grape", "Mango"];
return List({
items: items,
render: (item) => Div({ class: 'p-3 hover:bg-base-200 transition-colors' }, [
Span({ class: 'font-medium' }, item)
])
render: (item) =>
Div({ class: "p-3 hover:bg-base-200 transition-colors" }, [
Span({ class: "font-medium" }, item),
]),
});
};
$mount(BasicDemo, '#demo-basic');
$mount(BasicDemo, "#demo-basic");
```
### With Header
@@ -65,22 +66,31 @@ $mount(BasicDemo, '#demo-basic');
```javascript
const HeaderDemo = () => {
const users = [
{ name: 'John Doe', email: 'john@example.com', status: 'active' },
{ name: 'Jane Smith', email: 'jane@example.com', status: 'inactive' },
{ name: 'Bob Johnson', email: 'bob@example.com', status: 'active' }
{ name: "John Doe", email: "john@example.com", status: "active" },
{ name: "Jane Smith", email: "jane@example.com", status: "inactive" },
{ name: "Bob Johnson", email: "bob@example.com", status: "active" },
];
return List({
items: users,
header: Div({ class: 'p-3 bg-primary/10 font-bold border-b border-base-300' }, 'Active Users'),
render: (user) => Div({ class: 'p-3 border-b border-base-300 hover:bg-base-200' }, [
Div({ class: 'font-medium' }, user.name),
Div({ class: 'text-sm opacity-70' }, user.email),
Span({ class: `badge badge-sm ${user.status === 'active' ? 'badge-success' : 'badge-ghost'} mt-1` }, user.status)
])
header: Div(
{ class: "p-3 bg-primary/10 font-bold border-b border-base-300" },
"Active Users",
),
render: (user) =>
Div({ class: "p-3 border-b border-base-300 hover:bg-base-200" }, [
Div({ class: "font-medium" }, user.name),
Div({ class: "text-sm opacity-70" }, user.email),
Span(
{
class: `badge badge-sm ${user.status === "active" ? "badge-success" : "badge-ghost"} mt-1`,
},
user.status,
),
]),
});
};
$mount(HeaderDemo, '#demo-header');
$mount(HeaderDemo, "#demo-header");
```
### With Icons
@@ -95,25 +105,36 @@ $mount(HeaderDemo, '#demo-header');
```javascript
const IconsDemo = () => {
const settings = [
{ icon: '🔊', label: 'Sound', description: 'Adjust volume and notifications' },
{ icon: '🌙', label: 'Display', description: 'Brightness and dark mode' },
{ icon: '🔒', label: 'Privacy', description: 'Security settings' },
{ icon: '🌐', label: 'Network', description: 'WiFi and connections' }
{
icon: "🔊",
label: "Sound",
description: "Adjust volume and notifications",
},
{ icon: "🌙", label: "Display", description: "Brightness and dark mode" },
{ icon: "🔒", label: "Privacy", description: "Security settings" },
{ icon: "🌐", label: "Network", description: "WiFi and connections" },
];
return List({
items: settings,
render: (item) => Div({ class: 'flex gap-3 p-3 hover:bg-base-200 transition-colors cursor-pointer' }, [
Div({ class: 'text-2xl' }, item.icon),
Div({ class: 'flex-1' }, [
Div({ class: 'font-medium' }, item.label),
Div({ class: 'text-sm opacity-60' }, item.description)
]),
Span({ class: 'opacity-40' }, '→')
])
render: (item) =>
Div(
{
class:
"flex gap-3 p-3 hover:bg-base-200 transition-colors cursor-pointer",
},
[
Div({ class: "text-2xl" }, item.icon),
Div({ class: "flex-1" }, [
Div({ class: "font-medium" }, item.label),
Div({ class: "text-sm opacity-60" }, item.description),
]),
Span({ class: "opacity-40" }, "→"),
],
),
});
};
$mount(IconsDemo, '#demo-icons');
$mount(IconsDemo, "#demo-icons");
```
### With Badges
@@ -128,24 +149,52 @@ $mount(IconsDemo, '#demo-icons');
```javascript
const BadgesDemo = () => {
const notifications = [
{ id: 1, message: 'New comment on your post', time: '5 min ago', unread: true },
{ id: 2, message: 'Your order has been shipped', time: '1 hour ago', unread: true },
{ id: 3, message: 'Welcome to the platform!', time: '2 days ago', unread: false },
{ id: 4, message: 'Weekly digest available', time: '3 days ago', unread: false }
{
id: 1,
message: "New comment on your post",
time: "5 min ago",
unread: true,
},
{
id: 2,
message: "Your order has been shipped",
time: "1 hour ago",
unread: true,
},
{
id: 3,
message: "Welcome to the platform!",
time: "2 days ago",
unread: false,
},
{
id: 4,
message: "Weekly digest available",
time: "3 days ago",
unread: false,
},
];
return List({
items: notifications,
render: (item) => Div({ class: `flex justify-between items-center p-3 border-b border-base-300 hover:bg-base-200 ${item.unread ? 'bg-primary/5' : ''}` }, [
Div({ class: 'flex-1' }, [
Div({ class: 'font-medium' }, item.message),
Div({ class: 'text-xs opacity-50' }, item.time)
]),
item.unread ? Span({ class: 'badge badge-primary badge-sm' }, 'New') : null
])
render: (item) =>
Div(
{
class: `flex justify-between items-center p-3 border-b border-base-300 hover:bg-base-200 ${item.unread ? "bg-primary/5" : ""}`,
},
[
Div({ class: "flex-1" }, [
Div({ class: "font-medium" }, item.message),
Div({ class: "text-xs opacity-50" }, item.time),
]),
item.unread
? Span({ class: "badge badge-primary badge-sm" }, "New")
: null,
],
),
});
};
$mount(BadgesDemo, '#demo-badges');
$mount(BadgesDemo, "#demo-badges");
```
### Interactive List
@@ -161,38 +210,49 @@ $mount(BadgesDemo, '#demo-badges');
const InteractiveDemo = () => {
const selected = $(null);
const items = [
{ id: 1, name: 'Project Alpha', status: 'In Progress' },
{ id: 2, name: 'Project Beta', status: 'Planning' },
{ id: 3, name: 'Project Gamma', status: 'Completed' },
{ id: 4, name: 'Project Delta', status: 'Review' }
{ id: 1, name: "Project Alpha", status: "In Progress" },
{ id: 2, name: "Project Beta", status: "Planning" },
{ id: 3, name: "Project Gamma", status: "Completed" },
{ id: 4, name: "Project Delta", status: "Review" },
];
const statusColors = {
'In Progress': 'badge-warning',
'Planning': 'badge-info',
'Completed': 'badge-success',
'Review': 'badge-accent'
"In Progress": "badge-warning",
Planning: "badge-info",
Completed: "badge-success",
Review: "badge-accent",
};
return Div({ class: 'flex flex-col gap-4' }, [
return Div({ class: "flex flex-col gap-4" }, [
List({
items: items,
render: (item) => Div({
class: `p-3 cursor-pointer transition-all hover:bg-base-200 ${selected() === item.id ? 'bg-primary/10 border-l-4 border-primary' : 'border-l-4 border-transparent'}`,
onclick: () => selected(item.id)
}, [
Div({ class: 'flex justify-between items-center' }, [
Div({ class: 'font-medium' }, item.name),
Span({ class: `badge ${statusColors[item.status]}` }, item.status)
])
])
render: (item) =>
Div(
{
class: `p-3 cursor-pointer transition-all hover:bg-base-200 ${selected() === item.id ? "bg-primary/10 border-l-4 border-primary" : "border-l-4 border-transparent"}`,
onclick: () => selected(item.id),
},
[
Div({ class: "flex justify-between items-center" }, [
Div({ class: "font-medium" }, item.name),
Span(
{ class: `badge ${statusColors[item.status]}` },
item.status,
),
]),
],
),
}),
() => selected()
? Div({ class: 'alert alert-info' }, `Selected: ${items.find(i => i.id === selected()).name}`)
: Div({ class: 'alert alert-soft' }, 'Select a project to see details')
() =>
selected()
? Div(
{ class: "alert alert-info" },
`Selected: ${items.find((i) => i.id === selected()).name}`,
)
: Div({ class: "alert alert-soft" }, "Select a project to see details"),
]);
};
$mount(InteractiveDemo, '#demo-interactive');
$mount(InteractiveDemo, "#demo-interactive");
```
### Reactive List (Todo App)
@@ -223,9 +283,7 @@ const ReactiveDemo = () => {
};
const toggleTodo = (id) => {
todos(todos().map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
todos(todos().map(t => t.id === id ? { ...t, done: !t.done } : t));
};
const deleteTodo = (id) => {
@@ -233,13 +291,12 @@ const ReactiveDemo = () => {
};
const pendingCount = () => todos().filter(t => !t.done).length;
$watch(()=> console.log(pendingCount()));
return Div({ class: 'flex flex-col gap-4' }, [
Div({ class: 'flex gap-2' }, [
Input({
placeholder: 'Add new task...',
value: newTodo,
class: 'flex-1',
oninput: (e) => newTodo(e.target.value),
onkeypress: (e) => e.key === 'Enter' && addTodo()
}),
@@ -247,24 +304,32 @@ const ReactiveDemo = () => {
]),
List({
items: todos,
render: (todo) => Div({ class: `flex items-center gap-3 p-2 border-b border-base-300 hover:bg-base-200 ${todo.done ? 'opacity-60' : ''}` }, [
Checkbox({
value: todo.done,
onclick: () => toggleTodo(todo.id)
}),
Span({
class: `flex-1 ${todo.done ? 'line-through' : ''}`,
onclick: () => toggleTodo(todo.id)
}, todo.text),
Button({
class: 'btn btn-ghost btn-xs btn-circle',
onclick: () => deleteTodo(todo.id)
}, '✕')
])
render: (item) => {
// Esta función busca siempre el estado actual del item dentro del signal
const it = () => todos().find(t => t.id === item.id) || item;
return Div({
class: () => `flex items-center gap-3 p-2 border-b border-base-300 ${it().done ? 'opacity-60' : ''}`
}, [
Checkbox({
value: () => it().done,
onclick: () => toggleTodo(item.id)
}),
Span({
class: () => `flex-1 ${it().done ? 'line-through' : ''}`,
onclick: () => toggleTodo(item.id)
}, () => it().text),
Button({
class: 'btn btn-ghost btn-xs btn-circle',
onclick: () => deleteTodo(item.id)
}, '✕')
]);
}
}),
Div({ class: 'text-sm opacity-70 mt-2' }, () => `${pendingCount()} tasks remaining`)
]);
};
$mount(ReactiveDemo, '#demo-reactive');
```
@@ -280,27 +345,45 @@ $mount(ReactiveDemo, '#demo-reactive');
```javascript
const AvatarDemo = () => {
const contacts = [
{ name: 'Alice Johnson', role: 'Developer', avatar: 'A', online: true },
{ name: 'Bob Smith', role: 'Designer', avatar: 'B', online: false },
{ name: 'Charlie Brown', role: 'Manager', avatar: 'C', online: true },
{ name: 'Diana Prince', role: 'QA Engineer', avatar: 'D', online: false }
{ name: "Alice Johnson", role: "Developer", avatar: "A", online: true },
{ name: "Bob Smith", role: "Designer", avatar: "B", online: false },
{ name: "Charlie Brown", role: "Manager", avatar: "C", online: true },
{ name: "Diana Prince", role: "QA Engineer", avatar: "D", online: false },
];
return List({
items: contacts,
render: (contact) => Div({ class: 'flex gap-3 p-3 hover:bg-base-200 transition-colors' }, [
Div({ class: `avatar ${contact.online ? 'online' : 'offline'}`, style: 'width: 48px' }, [
Div({ class: 'rounded-full bg-primary text-primary-content flex items-center justify-center w-12 h-12 font-bold' }, contact.avatar)
render: (contact) =>
Div({ class: "flex gap-3 p-3 hover:bg-base-200 transition-colors" }, [
Div(
{
class: `avatar ${contact.online ? "online" : "offline"}`,
style: "width: 48px",
},
[
Div(
{
class:
"rounded-full bg-primary text-primary-content flex items-center justify-center w-12 h-12 font-bold",
},
contact.avatar,
),
],
),
Div({ class: "flex-1" }, [
Div({ class: "font-medium" }, contact.name),
Div({ class: "text-sm opacity-60" }, contact.role),
]),
Div(
{
class: `badge badge-sm ${contact.online ? "badge-success" : "badge-ghost"}`,
},
contact.online ? "Online" : "Offline",
),
]),
Div({ class: 'flex-1' }, [
Div({ class: 'font-medium' }, contact.name),
Div({ class: 'text-sm opacity-60' }, contact.role)
]),
Div({ class: `badge badge-sm ${contact.online ? 'badge-success' : 'badge-ghost'}` }, contact.online ? 'Online' : 'Offline')
])
});
};
$mount(AvatarDemo, '#demo-avatar');
$mount(AvatarDemo, "#demo-avatar");
```
### All Variants
@@ -314,29 +397,29 @@ $mount(AvatarDemo, '#demo-avatar');
```javascript
const VariantsDemo = () => {
const items = ['Item 1', 'Item 2', 'Item 3'];
const items = ["Item 1", "Item 2", "Item 3"];
return Div({ class: 'flex flex-col gap-6' }, [
Div({ class: 'text-sm font-bold' }, 'Default List'),
return Div({ class: "flex flex-col gap-6" }, [
Div({ class: "text-sm font-bold" }, "Default List"),
List({
items: items,
render: (item) => Div({ class: 'p-2' }, item)
render: (item) => Div({ class: "p-2" }, item),
}),
Div({ class: 'text-sm font-bold mt-2' }, 'With Shadow'),
Div({ class: "text-sm font-bold mt-2" }, "With Shadow"),
List({
items: items,
render: (item) => Div({ class: 'p-2' }, item),
class: 'shadow-lg'
render: (item) => Div({ class: "p-2" }, item),
class: "shadow-lg",
}),
Div({ class: 'text-sm font-bold mt-2' }, 'Rounded Corners'),
Div({ class: "text-sm font-bold mt-2" }, "Rounded Corners"),
List({
items: items,
render: (item) => Div({ class: 'p-2' }, item),
class: 'rounded-box overflow-hidden'
})
render: (item) => Div({ class: "p-2" }, item),
class: "rounded-box overflow-hidden",
}),
]);
};
$mount(VariantsDemo, '#demo-variants');
$mount(VariantsDemo, "#demo-variants");
```

View File

@@ -140,41 +140,6 @@ const ReactiveDemo = () => {
$mount(ReactiveDemo, '#demo-reactive');
```
### With Trend Indicators
<div class="card bg-base-200 border border-base-300 shadow-sm my-6">
<div class="card-body">
<h3 class="card-title text-sm uppercase opacity-50 mb-4">Live Demo</h3>
<div id="demo-trends" class="bg-base-100 p-6 rounded-xl border border-base-300 grid grid-cols-1 md:grid-cols-3 gap-4"></div>
</div>
</div>
```javascript
const TrendsDemo = () => {
return Div({ class: 'grid grid-cols-1 md:grid-cols-3 gap-4' }, [
Stat({
label: 'Weekly Sales',
value: '$12,345',
desc: Div({ class: 'text-success' }, '↗︎ 15% increase'),
icon: Span({ class: 'text-2xl' }, '📈')
}),
Stat({
label: 'Bounce Rate',
value: '42%',
desc: Div({ class: 'text-error' }, '↘︎ 3% from last week'),
icon: Span({ class: 'text-2xl' }, '📉')
}),
Stat({
label: 'Avg. Session',
value: '4m 32s',
desc: Div({ class: 'text-warning' }, '↗︎ 12 seconds'),
icon: Span({ class: 'text-2xl' }, '⏱️')
})
]);
};
$mount(TrendsDemo, '#demo-trends');
```
### Multiple Stats in Row
<div class="card bg-base-200 border border-base-300 shadow-sm my-6">

View File

@@ -294,27 +294,22 @@ $mount(ColorsDemo, '#demo-colors');
const AllPositionsDemo = () => {
return Div({ class: 'grid grid-cols-3 gap-4 justify-items-center' }, [
Div({ class: 'col-start-2' }, [
Tooltip({ tip: 'Top tooltip', class: 'tooltip-top' }, [
Tooltip({ tip: 'Top tooltip', ui: 'tooltip-top' }, [
Button({ class: 'btn btn-sm w-24' }, 'Top')
])
]),
Div({ class: 'col-start-1 row-start-2' }, [
Tooltip({ tip: 'Left tooltip', class: 'tooltip-left' }, [
Tooltip({ tip: 'Left tooltip', ui: 'tooltip-left' }, [
Button({ class: 'btn btn-sm w-24' }, 'Left')
])
]),
Div({ class: 'col-start-2 row-start-2' }, [
Tooltip({ tip: 'Center tooltip', class: 'tooltip' }, [
Button({ class: 'btn btn-sm w-24' }, 'Center')
])
]),
Div({ class: 'col-start-3 row-start-2' }, [
Tooltip({ tip: 'Right tooltip', class: 'tooltip-right' }, [
Tooltip({ tip: 'Right tooltip', ui: 'tooltip-right' }, [
Button({ class: 'btn btn-sm w-24' }, 'Right')
])
]),
Div({ class: 'col-start-2 row-start-3' }, [
Tooltip({ tip: 'Bottom tooltip', class: 'tooltip-bottom' }, [
Tooltip({ tip: 'Bottom tooltip', ui: 'tooltip-bottom' }, [
Button({ class: 'btn btn-sm w-24' }, 'Bottom')
])
])

View File

@@ -6,7 +6,7 @@ Follow these steps to integrate **SigPro-UI** into your project.
!> **📘 Core Concepts**
**Note:** SigPro-UI now includes SigPro core internally. You no longer need to install SigPro separately.
**Note:** SigPro-UI now includes SigPro core internally. No need to install SigPro separately.
SigProUI is built on top of the [SigPro](https://natxocc.github.io/sigpro/#/) reactive core. To learn how to create signals, manage reactivity, and structure your application logic, check out the [SigPro documentation](https://natxocc.github.io/sigpro/#/). It covers everything you need to build reactive applications with signals, computed values, and effects.
---
@@ -135,7 +135,6 @@ When you install SigProUI, you get:
- And 30+ more components!
### Utilities
- `Utils` - Helper functions (ui, val)
- `tt()` - i18n translation function
## Language Support
@@ -143,7 +142,7 @@ When you install SigProUI, you get:
SigProUI includes built-in i18n with Spanish and English:
```javascript
import { tt } from 'sigpro-ui';
import { tt, Locale } from 'sigpro-ui';
// Change locale (default is 'es')
Locale('en');

View File

@@ -1,6 +1,5 @@
# SigPro-UI Quick Reference
**Version:** daisyUI v5 + Tailwind v4 Plugin
**Status:** Active / WIP
@@ -8,9 +7,7 @@
```javascript
import "sigpro-ui";
// Injects all components into window and sets default language
Locale('en'); // 'es' or 'en'
import "sigpro-ui/css";
// All components (Button, Input, Table, Toast, etc.) are now globally available.
```
@@ -22,7 +19,7 @@ Locale('en'); // 'es' or 'en'
| Component | Purpose | Basic Example |
| :--- | :--- | :--- |
| **Button** | Styled button with DaisyUI | `Button({ class: "btn-primary" }, "Submit")` |
| **Input** | Reactive text field with floating label | `Input({ label: "Name", value: $name })` |
| **Input** | Reactive text field with validation | `Input({ value: $name, validate: (v) => !v ? "Required" : "" })` |
| **Select** | Dropdown selection menu | `Select({ options: ["Admin", "User"], value: $role })` |
| **Checkbox** | Binary toggle (boolean) | `Checkbox({ label: "Active", checked: $isActive })` |
| **Table** | Data grid with column rendering | `Table({ items: $data, columns: [...] })` |
@@ -40,7 +37,7 @@ Locale('en'); // 'es' or 'en'
| Component | Description | Example |
| :--- | :--- | :--- |
| **Input** | Text input with floating label, validation, password toggle | `Input({ label: "Email", type: "email", value: $email })` |
| **Input** | Text input with floating label, validation, password toggle | `Input({ label: "Email", type: "email", value: $email, validate: validateEmail })` |
| **Select** | Dropdown selector | `Select({ label: "Role", options: ["Admin", "User"], value: $role })` |
| **Autocomplete** | Searchable dropdown with filtering | `Autocomplete({ label: "Country", options: countryList, value: $country })` |
| **Datepicker** | Date picker (single or range mode) | `Datepicker({ label: "Date", value: $date, range: false })` |
@@ -53,6 +50,36 @@ Locale('en'); // 'es' or 'en'
---
## Input Validation
The `Input` component supports real-time validation via the `validate` prop:
```javascript
const email = $('');
Input({
type: 'email',
value: email,
placeholder: 'Enter your email',
icon: 'icon-[lucide--mail]',
validate: (value) => {
if (!value) return '';
if (!value.includes('@')) return 'Email must contain @';
if (!value.includes('.')) return 'Email must contain .';
return '';
},
oninput: (e) => email(e.target.value)
})
```
**How it works:**
- Returns `''` or `null` → no error
- Returns a string → shows error message and adds `input-error` class
- Validates on every keystroke
- No external state needed for error messages
---
## Data Display
| Component | Description | Example |
@@ -63,7 +90,7 @@ Locale('en'); // 'es' or 'en'
| **Stat** | Statistical data blocks (KPIs) | `Stat({ label: "Total", value: "1.2k", desc: "Monthly" })` |
| **Timeline** | Vertical/horizontal timeline | `Timeline({ items: [{ title: "Step 1", detail: "Completed" }] })` |
| **Stack** | Stacked elements | `Stack({}, [Card1, Card2, Card3])` |
| **Indicator** | Badge on corner of element | `Indicator({ badge: "3" }, Button(...))` |
| **Indicator** | Badge on corner of element | `Indicator({ value: () => count() }, Button(...))` |
---
@@ -74,7 +101,7 @@ Locale('en'); // 'es' or 'en'
| **Alert** | Inline contextual notification | `Alert({ type: "success" }, "Changes saved!")` |
| **Modal** | Dialog overlay | `Modal({ open: $isOpen, title: "Confirm" }, "Are you sure?")` |
| **Toast** | Floating notification (auto-stacking) | `Toast("Action completed", "alert-info", 3000)` |
| **Tooltip** | Hover tooltip wrapper | `Tooltip({ tip: "Help text" }, Button(...))` |
| **Tooltip** | Hover tooltip wrapper | `Tooltip({ tip: "Help text", ui: "tooltip-top" }, Button(...))` |
---
@@ -97,7 +124,7 @@ Locale('en'); // 'es' or 'en'
| Component | Description | Example |
| :--- | :--- | :--- |
| **Fab** | Floating Action Button with actions | `Fab({ icon: "+", actions: [{ label: "Add", onclick: add }] })` |
| **Indicator** | Badge indicator wrapper | `Indicator({ badge: "99+" }, Button(...))` |
| **Indicator** | Badge indicator wrapper | `Indicator({ value: () => unread() }, Button(...))` |
---
@@ -138,15 +165,16 @@ const closeText = tt("close"); // "Close" or "Cerrar"
```javascript
const name = $("");
const error = $(null);
Input({
value: name,
error: error,
oninput: (e) => {
name(e.target.value);
error(e.target.value.length < 3 ? "Name too short" : null);
}
placeholder: "Name",
validate: (value) => {
if (!value) return "Name is required";
if (value.length < 3) return "Name too short";
return "";
},
oninput: (e) => name(e.target.value)
})
```
@@ -186,14 +214,14 @@ Modal({
| Component | Key Props |
| :--- | :--- |
| `Button` | `class`, `disabled`, `loading`, `badge`, `tooltip`, `icon` |
| `Input` | `label`, `value`, `error`, `type`, `placeholder`, `disabled`, `tip` |
| `Button` | `class`, `disabled`, `loading`, `icon` |
| `Input` | `value`, `validate`, `type`, `placeholder`, `icon`, `disabled` |
| `Select` | `label`, `options`, `value`, `disabled` |
| `Modal` | `open`, `title`, `buttons` |
| `Table` | `items`, `columns`, `zebra`, `pinRows`, `empty` |
| `Alert` | `type` (info/success/warning/error), `soft`, `actions` |
| `Toast` | `message`, `type`, `duration` |
| `Loading` | `show` |
| `Datepicker` | `value`, `range`, `label`, `placeholder` |
| `Autocomplete` | `options`, `value`, `onSelect`, `label` |
| `Indicator` | `value` (function that returns number/string) |
| `Tooltip` | `tip`, `ui` (tooltip-top/bottom/left/right) |

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
// index.js
import 'sigpro';
import './src/sigpro.js';
// import './src/css/sigpro.css'; // No importes CSS en JS
import * as Components from './src/components/index.js';
// import * as Icons from './src/core/icons.js'; // ELIMINAR

View File

@@ -1,12 +1,15 @@
{
"name": "sigpro-ui",
"version": "1.1.0",
"type": "module",
"license": "MIT",
"main": "./index.js",
"module": "./index.js",
"unpkg": "./dist/sigpro-ui.min.js",
"jsdelivr": "./dist/sigpro-ui.min.js",
"devDependencies": {
"@iconify/json": "^2.2.458",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"daisyui": "^5.5.19",
"tailwindcss": "^4.2.2"
},
"exports": {
".": {
"import": "./index.js",
@@ -24,6 +27,8 @@
"README.md",
"LICENSE"
],
"jsdelivr": "./dist/sigpro-ui.min.js",
"license": "MIT",
"scripts": {
"build:cssmin": "./node_modules/.bin/tailwindcss -i ./src/css/sigpro.css -o ./css/sigpro.min.css --content './src/**/*.js' --minify",
"build:css": "./node_modules/.bin/tailwindcss -i ./src/css/sigpro.css -o ./css/sigpro.css --content './src/**/*.js'",
@@ -34,14 +39,6 @@
"prepublishOnly": "bun run build",
"docs": "bun x serve docs"
},
"dependencies": {
"sigpro": "^1.1.18"
},
"devDependencies": {
"@iconify/json": "^2.2.458",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"daisyui": "^5.5.19",
"tailwindcss": "^4.2.2"
}
"type": "module",
"unpkg": "./dist/sigpro-ui.min.js"
}

View File

@@ -1,5 +1,5 @@
// components/Accordion.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui, val } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Alert.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui, getIcon } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Autocomplete.js
import { $, $html, $for } from "sigpro";
import { $, $html, $for } from "../sigpro.js";
import { val } from "../core/utils.js";
import { tt } from "../core/i18n.js";
import { Input } from "./Input.js";

View File

@@ -1,5 +1,5 @@
// components/Badge.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Button.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui, val, getIcon } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Checkbox.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Colorpicker.js
import { $, $html, $if } from "sigpro";
import { $, $html, $if } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Datepicker.js
import { $, $html, $if } from "sigpro";
import { $, $html, $if } from "../sigpro.js";
import { val, ui, getIcon } from "../core/utils.js";
import { Input } from "./Input.js";

View File

@@ -1,5 +1,5 @@
// components/Drawer.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Dropdown.js
// import { $html, $for, $watch } from "sigpro";
// import { $html, $for, $watch } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Fab.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui, getIcon } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Fieldset.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Fileinput.js
import { $, $html, $if, $for } from "sigpro";
import { $, $html, $if, $for } from "../sigpro.js";
import { ui, getIcon } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Indicator.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Input.js
import { $, $html } from "sigpro";
import { $, $html, $watch } from "../sigpro.js";
import { val, ui, getIcon } from "../core/utils.js";
/**
@@ -20,12 +20,14 @@ export const Input = (props) => {
oninput,
placeholder,
disabled,
size, // para poder pasar el tamaño también al botón
size,
validate,
...rest
} = props;
const isPassword = type === "password";
const visible = $(false);
const errorMsg = $(null);
const iconMap = {
text: "icon-[lucide--text]",
@@ -39,7 +41,6 @@ export const Input = (props) => {
};
const leftIcon = icon ? getIcon(icon) : (iconMap[type] ? getIcon(iconMap[type]) : null);
const getPasswordIcon = () => getIcon(visible() ? "icon-[lucide--eye-off]" : "icon-[lucide--eye]");
const paddingLeft = leftIcon ? "pl-10" : "";
@@ -52,25 +53,43 @@ export const Input = (props) => {
return 'btn-md';
};
const handleInput = (e) => {
const newValue = e.target.value;
if (validate) {
const result = validate(newValue);
errorMsg(result || null);
}
oninput?.(e);
};
const hasError = () => errorMsg() && errorMsg() !== '';
const inputClasses = () => {
let classes = `input w-full ${paddingLeft} ${paddingRight}`;
if (className) classes += ` ${className}`;
if (hasError()) classes += ' input-error';
return classes.trim();
};
const inputElement = $html("input", {
...rest,
type: () => (isPassword ? (visible() ? "text" : "password") : type),
placeholder: placeholder || " ",
class: inputClasses,
value: value,
oninput: handleInput,
disabled: () => val(disabled),
"aria-invalid": () => hasError() ? "true" : "false",
});
return $html(
"div",
{ class: "relative w-full" },
() => [
// Input
$html("input", {
...rest,
type: () => (isPassword ? (visible() ? "text" : "password") : type),
placeholder: placeholder || " ",
class: ui('input w-full', `${paddingLeft} ${paddingRight} ${className || ''}`.trim()),
value: value,
oninput: (e) => oninput?.(e),
disabled: () => val(disabled),
}),
inputElement,
leftIcon ? $html("div", {
class: "absolute left-3 inset-y-0 flex items-center pointer-events-none text-base-content/60",
}, leftIcon) : null,
isPassword ? $html("button", {
type: "button",
class: ui(
@@ -83,6 +102,9 @@ export const Input = (props) => {
visible(!visible());
}
}, () => getPasswordIcon()) : null,
$html("div", {
class: "text-error text-xs mt-1 px-3 absolute -bottom-5 left-0",
}, () => hasError() ? errorMsg() : null),
]
);
};

View File

@@ -1,5 +1,5 @@
// components/Label.js
import { $, $html } from "sigpro";
import { $, $html } from "../sigpro.js";
import { ui, val } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/List.js
import { $html, $if, $for } from "sigpro";
import { $html, $if, $for } from "../sigpro.js";
import { ui, val } from "../core/utils.js";
/**
@@ -12,7 +12,7 @@ import { ui, val } from "../core/utils.js";
* - flex, items-center, gap-2
*/
export const List = (props) => {
const { class: className, items, header, render, keyFn = (item, index) => index, ...rest } = props;
const { class: className, items, header, render, keyFn = (item, index) => item.id ?? index, ...rest } = props;
const listItems = $for(
items,
@@ -20,12 +20,8 @@ export const List = (props) => {
keyFn
);
return $html(
"ul",
{
...rest,
class: ui('list bg-base-100 rounded-box shadow-md', className),
},
header ? [$if(header, () => $html("li", { class: "p-4 pb-2 text-xs opacity-60" }, [val(header)])), listItems] : listItems
);
return $html("ul", {
...rest,
class: ui('list bg-base-100 rounded-box shadow-md', className),
}, header ? [$if(header, () => $html("li", { class: "p-4 pb-2 text-xs opacity-60" }, [val(header)])), listItems] : listItems);
};

View File

@@ -1,5 +1,5 @@
// components/Menu.js
import { $html, $for } from "sigpro";
import { $html, $for } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Modal.js
import { $html, $watch } from "sigpro";
import { $html, $watch } from "../sigpro.js";
import { ui } from "../core/utils.js";
import { tt } from "../core/i18n.js";
import { Button } from "./Button.js";

View File

@@ -1,5 +1,5 @@
// components/Navbar.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Radio.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Range.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Rating.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Select.js
import { $html, $for } from "sigpro";
import { $html, $for } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Stack.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Stat.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Swap.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui, val } from "../core/utils.js";
/**

View File

@@ -1,5 +1,5 @@
// components/Table.js
import { $html, $for, $if } from "sigpro";
import { $html, $for, $if } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
import { tt } from "../core/i18n.js";
@@ -22,6 +22,8 @@ export const Table = (props) => {
return ui('table', className, zebraClass, pinRowsClass);
};
const getInternalKeyFn = keyFn || ((item, idx) => item.id || idx);
return $html("div", { class: "overflow-x-auto w-full bg-base-100 rounded-box border border-base-300" }, [
$html("table", { ...rest, class: tableClass }, [
$html("thead", {}, [
@@ -31,17 +33,24 @@ export const Table = (props) => {
]),
$html("tbody", {}, [
$for(items, (item, index) => {
const it = () => {
const currentItems = val(items);
const key = getInternalKeyFn(item, index);
return currentItems.find((u, i) => getInternalKeyFn(u, i) === key) || item;
};
return $html("tr", { class: "hover" },
columns.map(col => {
const cellContent = () => {
if (col.render) return col.render(item, index);
const value = item[col.key];
return val(value);
const latestItem = it();
if (col.render) return col.render(latestItem, index);
return val(latestItem[col.key]);
};
return $html("td", { class: col.class || "" }, [cellContent]);
})
);
}, keyFn || ((item, idx) => item.id || idx)),
}, getInternalKeyFn),
$if(() => val(items).length === 0, () =>
$html("tr", {}, [
@@ -50,14 +59,7 @@ export const Table = (props) => {
])
])
)
]),
$if(() => columns.some(c => c.footer), () =>
$html("tfoot", {}, [
$html("tr", {},
columns.map(col => $html("th", {}, col.footer || ""))
)
])
)
])
])
]);
};

View File

@@ -1,5 +1,5 @@
// components/Tabs.js
import { $, $html, $for } from "sigpro";
import { $, $html, $for } from "../sigpro.js";
import { val, ui } from "../core/utils.js";
/**
@@ -13,58 +13,63 @@ import { val, ui } from "../core/utils.js";
export const Tabs = (props) => {
const { items, class: className, ...rest } = props;
const itemsSignal = typeof items === "function" ? items : () => items || [];
const name = `tabs-${Math.random().toString(36).slice(2, 9)}`;
const activeIndex = $(0);
// Encontrar el índice activo
const getActiveIndex = () => {
const arr = itemsSignal();
const idx = arr.findIndex(it => val(it.active) === true);
return idx === -1 ? 0 : idx;
};
$watch(() => {
const idx = itemsSignal().findIndex(it => val(it.active) === true);
if (idx !== -1 && idx !== activeIndex()) activeIndex(idx);
});
const activeIndex = $(getActiveIndex);
return $html("div", { ...rest, class: "w-full" }, [
// 1. Tab List: Aplanamos los botones para que sean hijos directos
$html("div", {
role: "tablist",
class: ui('tabs', className || 'tabs-box')
}, () => {
const list = itemsSignal();
return list.map((it, idx) => {
const isSelected = () => activeIndex() === idx;
const updateActiveIndex = () => {
const newIndex = getActiveIndex();
if (newIndex !== activeIndex()) activeIndex(newIndex);
};
$watch(() => updateActiveIndex());
return $html("div", {
...rest,
class: ui('tabs', className || 'tabs-box')
}, [
$for(itemsSignal, (it, idx) => {
const isChecked = () => activeIndex() === idx;
const getLabelText = () => {
const label = typeof it.label === "function" ? it.label() : it.label;
return typeof label === "string" ? label : `Tab ${idx + 1}`;
};
return [
$html("input", {
type: "radio",
name: name,
class: "tab",
"aria-label": getLabelText(),
checked: isChecked, // ← función reactiva, no string hardcodeado
disabled: () => val(it.disabled),
onchange: (e) => {
if (e.target.checked && !val(it.disabled)) {
const tab = $html("button", {
role: "tab",
class: () => ui("tab", isSelected() ? "tab-active" : ""),
onclick: (e) => {
e.preventDefault();
if (!val(it.disabled)) {
if (it.onclick) it.onclick();
if (typeof it.active === "function") it.active(true);
activeIndex(idx);
}
}
}),
$html("div", {
});
// Mantenemos el watch para el label por si es dinámico
$watch(() => {
const content = val(it.label);
if (content instanceof Node) {
tab.replaceChildren(content);
} else {
tab.textContent = String(content);
}
});
return tab;
});
}),
// 2. Tab Content: Aquí el display:contents no molesta tanto,
// pero lo aplanamos por consistencia
$html("div", { class: "tab-panels" }, () => {
return itemsSignal().map((it, idx) => {
const isVisible = () => activeIndex() === idx;
return $html("div", {
role: "tabpanel",
class: "tab-content bg-base-100 border-base-300 p-6",
style: () => isChecked() ? "display: block" : "display: none"
style: () => isVisible() ? "display: block" : "display: none"
}, [
typeof it.content === "function" ? it.content() : it.content
])
];
}, (it, idx) => idx)
() => typeof it.content === "function" ? it.content() : it.content
]);
});
})
]);
};

View File

@@ -1,5 +1,5 @@
// components/Timeline.js
import { $html, $for } from "sigpro";
import { $html, $for } from "../sigpro.js";
import { val, ui, getIcon } from "../core/utils.js";
/**
@@ -21,38 +21,39 @@ export const Timeline = (props) => {
error: "icon-[lucide--alert-circle]",
};
const itemsSource = typeof items === "function" ? items : () => items || [];
return $html(
"ul",
{
...rest,
class: () => ui(
`timeline ${val(vertical) ? "timeline-vertical" : "timeline-horizontal"} ${val(compact) ? "timeline-compact" : ""}`, className),
},
[
$for(
itemsSource,
(item, i) => {
const isFirst = i === 0;
const isLast = i === itemsSource().length - 1;
const itemType = item.type || "success";
const renderSlot = (content) => (typeof content === "function" ? content() : content);
return $html("li", { class: "flex-1" }, [
!isFirst ? $html("hr", { class: () => item.completed ? "bg-primary" : "" }) : null,
$html("div", { class: "timeline-start" }, () => renderSlot(item.title)),
$html("div", { class: "timeline-middle" }, () => [
item.icon
? getIcon(item.icon)
: getIcon(iconMap[itemType] || iconMap.success)
]),
$html("div", { class: "timeline-end timeline-box shadow-sm" }, () => renderSlot(item.detail)),
!isLast ? $html("hr", { class: () => item.completed ? "bg-primary" : "" }) : null,
]);
},
(item, i) => item.id || i,
`timeline ${val(vertical) ? "timeline-vertical" : "timeline-horizontal"} ${val(compact) ? "timeline-compact" : ""}`,
className
),
],
},
() => {
const list = (typeof items === "function" ? items() : items) || [];
return list.map((item, i) => {
const isFirst = i === 0;
const isLast = i === list.length - 1;
const itemType = item.type || "success";
const isCompleted = () => val(item.completed);
// Nueva lógica: La línea de entrada se pinta si el ANTERIOR estaba completado
const prevCompleted = () => i > 0 && val(list[i - 1].completed);
const renderSlot = (content) => (typeof content === "function" ? content() : content);
return $html("li", { class: "flex-1" }, [
!isFirst ? $html("hr", { class: () => prevCompleted() ? "bg-primary" : "" }) : null,
$html("div", { class: "timeline-start" }, [() => renderSlot(item.title)]),
$html("div", { class: "timeline-middle" }, [
() => item.icon ? getIcon(item.icon) : getIcon(iconMap[itemType] || iconMap.success)
]),
$html("div", { class: "timeline-end timeline-box shadow-sm" }, [() => renderSlot(item.detail)]),
!isLast ? $html("hr", { class: () => isCompleted() ? "bg-primary" : "" }) : null,
]);
});
}
);
};

View File

@@ -1,5 +1,5 @@
// components/Toast.js
import { $html, $mount } from "sigpro";
import { $html, $mount } from "../sigpro.js";
import { getIcon } from "../core/utils.js";
import { Button } from "./Button.js";

View File

@@ -1,5 +1,5 @@
// components/Tooltip.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
import { ui } from "../core/utils.js";
/**

View File

@@ -1,4 +1,4 @@
import { $ } from "sigpro";
import { $ } from "../sigpro.js";
export const i18n = {
es: {

View File

@@ -1,5 +1,5 @@
// core/utils.js
import { $html } from "sigpro";
import { $html } from "../sigpro.js";
export const val = t => typeof t === "function" ? t() : t;

482
src/sigpro.js Normal file
View File

@@ -0,0 +1,482 @@
/**
* SigPro Core
*/
let activeEffect = null;
let currentOwner = null;
const effectQueue = new Set();
let isFlushing = false;
const MOUNTED_NODES = new WeakMap();
/** flush */
const flush = () => {
if (isFlushing) return;
isFlushing = true;
while (effectQueue.size > 0) {
const sorted = Array.from(effectQueue).sort((a, b) => (a.depth || 0) - (b.depth || 0));
effectQueue.clear();
for (const eff of sorted) if (!eff._deleted) eff();
}
isFlushing = false;
};
/** track */
const track = (subs) => {
if (activeEffect && !activeEffect._deleted) {
subs.add(activeEffect);
activeEffect._deps.add(subs);
}
};
/** trigger */
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);
};
/** sweep */
const sweep = (node) => {
if (node._cleanups) {
node._cleanups.forEach((f) => f());
node._cleanups.clear();
}
node.childNodes?.forEach(sweep);
};
/** _view */
const _view = (fn) => {
const cleanups = new Set();
const prev = currentOwner;
const container = document.createElement("div");
container.style.display = "contents";
currentOwner = { cleanups };
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();
},
};
};
/**
* Creates a reactive Signal or a Computed Value.
* @param {any|Function} initial - Initial value or a getter function for computed state.
* @param {string} [key] - Optional. Key for automatic persistence in localStorage.
* @returns {Function} Signal getter/setter. Use `sig()` to read and `sig(val)` to write.
* @example
* const count = $(0); // Simple signal
* const double = $(() => count() * 2); // Computed signal
* const name = $("John", "user-name"); // Persisted signal
*/
const $ = (initial, key = null) => {
if (typeof initial === "function") {
const subs = new Set();
let cached, dirty = true;
const effect = () => {
if (effect._deleted) return;
effect._deps.forEach((s) => s.delete(effect));
effect._deps.clear();
const prev = activeEffect;
activeEffect = effect;
try {
const val = initial();
if (!Object.is(cached, val) || dirty) {
cached = val;
dirty = false;
trigger(subs);
}
} finally { activeEffect = prev; }
};
effect._deps = new Set();
effect._isComputed = true;
effect._subs = subs;
effect._deleted = false;
effect.markDirty = () => (dirty = true);
effect.stop = () => {
effect._deleted = true;
effect._deps.forEach((s) => s.delete(effect));
subs.clear();
};
if (currentOwner) currentOwner.cleanups.add(effect.stop);
return () => { if (dirty) effect(); track(subs); return cached; };
}
let value = initial;
if (key) {
try {
const saved = localStorage.getItem(key);
if (saved !== null) value = JSON.parse(saved);
} catch (e) {
console.warn("SigPro: LocalStorage locked", e);
}
}
const subs = new Set();
return (...args) => {
if (args.length) {
const next = typeof args[0] === "function" ? args[0](value) : args[0];
if (!Object.is(value, next)) {
value = next;
if (key) localStorage.setItem(key, JSON.stringify(value));
trigger(subs);
}
}
track(subs);
return value;
};
};
/**
* Watches for signal changes and executes a side effect.
* Handles automatic cleanup of previous effects.
* @param {Function|Array} target - Function to execute or Array of signals for explicit dependency tracking.
* @param {Function} [fn] - If the first parameter is an Array, this is the callback function.
* @returns {Function} Function to manually stop the watcher.
* @example
* $watch(() => console.log("Count is:", count()));
* $watch([count], () => console.log("Only runs when count changes"));
*/
const $watch = (target, fn) => {
const isExplicit = Array.isArray(target);
const callback = isExplicit ? fn : target;
const depsInput = isExplicit ? target : null;
if (typeof callback !== "function") return () => { };
const owner = currentOwner;
const runner = () => {
if (runner._deleted) return;
runner._deps.forEach((s) => s.delete(runner));
runner._deps.clear();
runner._cleanups.forEach((c) => c());
runner._cleanups.clear();
const prevEffect = activeEffect;
const prevOwner = currentOwner;
activeEffect = runner;
currentOwner = { cleanups: runner._cleanups };
runner.depth = prevEffect ? prevEffect.depth + 1 : 0;
try {
if (isExplicit) {
activeEffect = null;
callback();
activeEffect = runner;
depsInput.forEach(d => typeof d === "function" && d());
} else {
callback();
}
} finally {
activeEffect = prevEffect;
currentOwner = prevOwner;
}
};
runner._deps = new Set();
runner._cleanups = new Set();
runner._deleted = false;
runner.stop = () => {
if (runner._deleted) return;
runner._deleted = true;
effectQueue.delete(runner);
runner._deps.forEach((s) => s.delete(runner));
runner._cleanups.forEach((c) => c());
if (owner) owner.cleanups.delete(runner.stop);
};
if (owner) owner.cleanups.add(runner.stop);
runner();
return runner.stop;
};
/**
* DOM element rendering engine with built-in reactivity.
* @param {string} tag - HTML tag name (e.g., 'div', 'span').
* @param {Object} [props] - Attributes, events (onEvent), or two-way bindings (value, checked).
* @param {Array|any} [content] - Children: text, other nodes, or reactive signals.
* @returns {HTMLElement} The configured reactive DOM element.
*/
const $html = (tag, props = {}, content = []) => {
if (props instanceof Node || Array.isArray(props) || typeof props !== "object") {
content = props; props = {};
}
const el = document.createElement(tag),
_sanitize = (key, val) => (key === 'src' || key === 'href') && String(val).toLowerCase().includes('javascript:') ? '#' : val;
el._cleanups = new Set();
const boolAttrs = ["disabled", "checked", "required", "readonly", "selected", "multiple", "autofocus"];
for (let [key, val] of Object.entries(props)) {
if (key === "ref") { (typeof val === "function" ? val(el) : (val.current = el)); continue; }
const isSignal = typeof val === "function",
isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName),
isBindAttr = (key === "value" || key === "checked");
if (isInput && isBindAttr && isSignal) {
el._cleanups.add($watch(() => { const currentVal = val(); if (el[key] !== currentVal) el[key] = currentVal; }));
const eventName = key === "checked" ? "change" : "input", handler = (event) => val(event.target[key]);
el.addEventListener(eventName, handler);
el._cleanups.add(() => el.removeEventListener(eventName, handler));
} else if (key.startsWith("on")) {
const eventName = key.slice(2).toLowerCase().split(".")[0], handler = (event) => val(event);
el.addEventListener(eventName, handler);
el._cleanups.add(() => el.removeEventListener(eventName, handler));
} else if (isSignal) {
el._cleanups.add($watch(() => {
const currentVal = _sanitize(key, val());
if (key === "class") {
el.className = currentVal || "";
} else if (boolAttrs.includes(key)) {
if (currentVal) {
el.setAttribute(key, "");
el[key] = true;
} else {
el.removeAttribute(key);
el[key] = false;
}
} else {
currentVal == null ? el.removeAttribute(key) : el.setAttribute(key, currentVal);
}
}));
} else {
if (boolAttrs.includes(key)) {
if (val) {
el.setAttribute(key, "");
el[key] = true;
} else {
el.removeAttribute(key);
el[key] = false;
}
} else {
el.setAttribute(key, _sanitize(key, val));
}
}
}
const append = (child) => {
if (Array.isArray(child)) return child.forEach(append);
if (child instanceof Node) {
el.appendChild(child);
} else if (typeof child === "function") {
const marker = document.createTextNode("");
el.appendChild(marker);
let nodes = [];
el._cleanups.add($watch(() => {
const res = child(), next = (Array.isArray(res) ? res : [res]).map((i) =>
i?._isRuntime ? i.container : i instanceof Node ? i : document.createTextNode(i ?? "")
);
nodes.forEach((n) => { sweep?.(n); n.remove(); });
next.forEach((n) => marker.parentNode?.insertBefore(n, marker));
nodes = next;
}));
} else el.appendChild(document.createTextNode(child ?? ""));
};
append(content);
return el;
};
/**
* Conditional rendering component.
* @param {Function|boolean} condition - Reactive signal or boolean value.
* @param {Function|HTMLElement} thenVal - Content to show if true.
* @param {Function|HTMLElement} [otherwiseVal] - Content to show if false (optional).
* @returns {HTMLElement} A reactive container (display: contents).
*/
const $if = (condition, thenVal, otherwiseVal = null) => {
const marker = document.createTextNode("");
const container = $html("div", { style: "display:contents" }, [marker]);
let current = null, last = null;
$watch(() => {
const state = !!(typeof condition === "function" ? condition() : condition);
if (state !== last) {
last = state;
if (current) current.destroy();
const branch = state ? thenVal : otherwiseVal;
if (branch) {
current = _view(() => typeof branch === "function" ? branch() : branch);
container.insertBefore(current.container, marker);
}
}
});
return container;
};
$if.not = (condition, thenVal, otherwiseVal) => $if(() => !(typeof condition === "function" ? condition() : condition), thenVal, otherwiseVal);
/**
* Optimized reactive loop with key-based reconciliation.
* @param {Function|Array} source - Signal containing an Array of data.
* @param {Function} render - Function receiving (item, index) and returning a node.
* @param {Function} keyFn - Function to extract a unique key from the item.
* @returns {HTMLElement} A reactive container (display: contents).
*/
const $for = (source, render, keyFn, tag = "div", props = { style: "display:contents" }) => {
const marker = document.createTextNode("");
const container = $html(tag, props, [marker]);
let cache = new Map();
$watch(() => {
const items = (typeof source === "function" ? source() : source) || [];
const newCache = new Map();
const newOrder = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const key = keyFn ? keyFn(item, i) : i;
let run = cache.get(key);
if (!run) {
run = _view(() => render(item, i));
} else {
cache.delete(key);
}
newCache.set(key, run);
newOrder.push(key);
}
cache.forEach(run => {
run.destroy();
run.container.remove();
});
let anchor = marker;
for (let i = newOrder.length - 1; i >= 0; i--) {
const run = newCache.get(newOrder[i]);
if (run.container.nextSibling !== anchor) {
container.insertBefore(run.container, anchor);
}
anchor = run.container;
}
cache = newCache;
});
return container;
};
/**
* Hash-based (#) routing system.
* @param {Array<{path: string, component: Function}>} routes - Route definitions.
* @returns {HTMLElement} The router outlet container.
*/
const $router = (routes) => {
const sPath = $(window.location.hash.replace(/^#/, "") || "/");
window.addEventListener("hashchange", () => sPath(window.location.hash.replace(/^#/, "") || "/"));
const outlet = $html("div", { class: "router-outlet" });
let current = null;
$watch([sPath], async () => {
const path = sPath();
const route = routes.find(r => {
const rp = r.path.split("/").filter(Boolean), pp = path.split("/").filter(Boolean);
return rp.length === pp.length && rp.every((p, i) => p.startsWith(":") || p === pp[i]);
}) || routes.find(r => r.path === "*");
if (route) {
let comp = route.component;
if (typeof comp === "function" && comp.toString().includes('import')) {
comp = (await comp()).default || (await comp());
}
const params = {};
route.path.split("/").filter(Boolean).forEach((p, i) => {
if (p.startsWith(":")) params[p.slice(1)] = path.split("/").filter(Boolean)[i];
});
if (current) current.destroy();
if ($router.params) $router.params(params);
current = _view(() => {
try {
return typeof comp === "function" ? comp(params) : comp;
} catch (e) {
return $html("div", { class: "p-4 text-error" }, "Error loading view");
}
});
outlet.appendChild(current.container);
}
});
return outlet;
};
$router.params = $({});
$router.to = (path) => (window.location.hash = path.replace(/^#?\/?/, "#/"));
$router.back = () => window.history.back();
$router.path = () => window.location.hash.replace(/^#/, "") || "/";
/**
* Mounts a component or node into a DOM target element.
* It automatically handles the cleanup of any previously mounted SigPro instances
* in that target to prevent memory leaks and duplicate renders.
* * @param {Function|HTMLElement} component - The component function to render or a pre-built DOM node.
* @param {string|HTMLElement} target - A CSS selector string or a direct DOM element to mount into.
* @returns {Object|undefined} The view instance containing the `container` and `destroy` method, or undefined if target is not found.
* * @example
* // Mount using a component function
* $mount(() => Div({ class: "app" }, "Hello World"), "#root");
* * // Mount using a direct element
* const myApp = Div("Hello");
* $mount(myApp, document.getElementById("app"));
*/
const $mount = (component, target) => {
const el = typeof target === "string" ? document.querySelector(target) : target;
if (!el) return;
if (MOUNTED_NODES.has(el)) MOUNTED_NODES.get(el).destroy();
const instance = _view(typeof component === "function" ? component : () => component);
el.replaceChildren(instance.container);
MOUNTED_NODES.set(el, instance);
return instance;
};
/** GLOBAL CORE REGISTRY */
const SigProCore = { $, $watch, $html, $if, $for, $router, $mount };
if (typeof window !== "undefined") {
const install = (registry) => {
Object.keys(registry).forEach(key => {
window[key] = registry[key];
});
const tags = `div span p h1 h2 h3 h4 h5 h6 br hr section article aside nav main header footer address ul ol li dl dt dd a em strong small i b u mark time sub sup pre code blockquote details summary dialog form label input textarea select button option fieldset legend table thead tbody tfoot tr th td caption img video audio canvas svg iframe picture source progress meter`.split(/\s+/);
tags.forEach((tagName) => {
const helperName = tagName.charAt(0).toUpperCase() + tagName.slice(1);
if (!(helperName in window)) {
window[helperName] = (props, content) => $html(tagName, props, content);
}
});
window.SigPro = Object.freeze(registry);
};
install(SigProCore);
}
export { $, $watch, $html, $if, $for, $router, $mount };
export default SigProCore;