This commit is contained in:
2026-03-25 03:17:28 +01:00
parent df8bd891a2
commit f4213e3162
3 changed files with 240 additions and 87 deletions

View File

@@ -7,11 +7,9 @@ import { $ } from "./sigpro";
*/ */
const $valor = $(""); const $valor = $("");
const $toggle = $(false); const $toggle = $(false);
// const consoleToggle = $(()=>console.log($toggle())) // const consoleToggle = $(()=>console.log($toggle()))
const Home = () => { const Home = () => {
const miCheck = $(false); // Creamos la señal const miCheck = $(false); // Creamos la señal
return Div({ class: "prose" }, [ return Div({ class: "prose" }, [
H1("Dashboard Principal"), H1("Dashboard Principal"),
@@ -43,6 +41,8 @@ const Home = () => {
const Profile = (params) => { const Profile = (params) => {
const miFecha = $({ start: null, end: null }); const miFecha = $({ start: null, end: null });
const selectedFruit = $("Apple");
const fruits = ["Apple", "Banana", "Cherry", "Dragonfruit", "Elderberry"];
const textoInput = $(() => { const textoInput = $(() => {
const f = miFecha; const f = miFecha;
@@ -52,7 +52,16 @@ const Profile = (params) => {
return Div({ class: "p-4 space-y-4" }, [ return Div({ class: "p-4 space-y-4" }, [
H2({ class: "text-xl font-bold" }, `Perfil: ${params.id}`), H2({ class: "text-xl font-bold" }, `Perfil: ${params.id}`),
Autocomplete({
label: "Selecciona una fruta",
$value: selectedFruit,
options: fruits,
onSelect: (val) => console.log("Seleccionado:", val),
}),
Input({type: "number", label: "Number", icon: "🔍"}),
Input({type: "date", label: "Date"}),
Input({type: "password", label: "Password"}),
$.html("p", {}, () => `Has elegido: ${selectedFruit()}`),
Div({ class: "pt-4" }, [ Div({ class: "pt-4" }, [
Button( Button(
{ {
@@ -72,7 +81,7 @@ export const App = () => {
const isDark = $(false, "sigpro-theme"); const isDark = $(false, "sigpro-theme");
// Efecto para cambiar el tema en el HTML // Efecto para cambiar el tema en el HTML
$(() => { $$(() => {
document.documentElement.setAttribute("data-theme", isDark() ? "dark" : "light"); document.documentElement.setAttribute("data-theme", isDark() ? "dark" : "light");
}); });

View File

@@ -51,7 +51,7 @@ export const UI = ($, defaultLang = "es") => {
const container = $.html("div", { style: "display:contents" }, [marker]); const container = $.html("div", { style: "display:contents" }, [marker]);
const cache = new Map(); const cache = new Map();
$(() => { $$(() => {
const items = val(source) || []; const items = val(source) || [];
const newKeys = new Set(); const newKeys = new Set();
@@ -181,32 +181,54 @@ export const UI = ($, defaultLang = "es") => {
}; };
/** INPUT */ /** INPUT */
ui.Input = (props) => { ui.Input = (props) => {
const { label, tip, $value, $error, isSearch, ...rest } = props; const { label, tip, $value, $error, isSearch, icon, ...rest } = props;
const inputEl = $.html("input", { // Estado local para alternar visibilidad si es password
...rest, const isPassword = props.type === "password";
placeholder: props.placeholder || (isSearch ? tt("search") : " "), const showPassword = $(false);
class: joinClass("input input-bordered w-full", props.$class || props.class),
// 1. Vinculamos el valor a la señal
$value: $value || props.value,
// 2. ACTUALIZAMOS la señal al escribir para que no se bloquee
oninput: (e) => {
if (typeof $value === "function") $value(e.target.value);
if (typeof props.oninput === "function") props.oninput(e);
},
$disabled: () => val(props.$disabled) || val(props.disabled),
});
if (!label && !tip && !$error) return inputEl; const inputEl = $.html("input", {
...rest,
// El tipo cambia dinámicamente si es password
type: () => (isPassword ? (showPassword() ? "text" : "password") : (props.type || "text")),
placeholder: props.placeholder || (isSearch ? tt("search")() : " "),
class: joinClass("grow order-2", props.$class || props.class),
$value: $value || props.value,
oninput: (e) => {
if (typeof $value === "function") $value(e.target.value);
if (typeof props.oninput === "function") props.oninput(e);
},
$disabled: () => val(props.$disabled) || val(props.disabled),
});
return $.html("label", { class: "input floating-label fieldset-label flex flex-col gap-1" }, [ return $.html("label", {
label ? $.html("span", {}, label) : null, class: "input input-bordered floating-label flex items-center gap-2 w-full relative"
tip ? $.html("div", { class: "tooltip tooltip-right", "data-tip": tip }, $.html("span", { class: "badge badge-ghost badge-xs" }, "?")) : null, }, [
inputEl, // 1. Icono Izquierda (opcional)
() => (val($error) ? $.html("span", { class: "text-error text-xs" }, val($error)) : null), icon ? $.html("div", { class: "order-1 flex items-center opacity-50 shrink-0" }, icon) : null,
]);
}; // 2. Texto del Label
label ? $.html("span", { class: "order-0" }, label) : null,
// 3. Input
inputEl,
// 4. Botón Ojo (Solo si es type="password")
isPassword ? $.html("button", {
type: "button",
class: "order-3 btn btn-ghost btn-xs btn-circle opacity-50 hover:opacity-100",
onclick: (e) => {
e.preventDefault();
showPassword(!showPassword());
}
}, () => (showPassword() ? "🙈" : "👁️")) : null,
// 5. Tooltip/Error
tip ? $.html("div", { class: "tooltip tooltip-right order-4", "data-tip": tip }, $.html("span", { class: "badge badge-ghost badge-xs" }, "?")) : null,
() => (val($error) ? $.html("span", { class: "text-error text-xs absolute -bottom-5 left-0" }, val($error)) : null),
]);
};
/** SELECT */ /** SELECT */
ui.Select = (props) => { ui.Select = (props) => {
@@ -240,6 +262,105 @@ export const UI = ($, defaultLang = "es") => {
return $.html("label", { class: "fieldset-label flex flex-col gap-1" }, [$.html("span", {}, label), selectEl]); return $.html("label", { class: "fieldset-label flex flex-col gap-1" }, [$.html("span", {}, label), selectEl]);
}; };
/** AUTOCOMPLETE */
ui.Autocomplete = (props) => {
const { options = [], $value, onSelect, label, placeholder, ...rest } = props;
const query = $(val($value) || "");
const isOpen = $(false);
const highlightedIndex = $(-1);
const filtered = $(() => {
const q = query().toLowerCase();
const list = val(options) || [];
if (!q) return list;
return list.filter((opt) => {
const text = typeof opt === "string" ? opt : opt.label;
return text.toLowerCase().includes(q);
});
});
const select = (opt) => {
const v = typeof opt === "string" ? opt : opt.value;
const l = typeof opt === "string" ? opt : opt.label;
query(l);
if (typeof $value === "function") $value(v);
onSelect?.(opt);
isOpen(false);
highlightedIndex(-1);
};
const onKeyDown = (e) => {
const list = filtered();
if (e.key === "ArrowDown") {
e.preventDefault();
isOpen(true);
highlightedIndex((prev) => Math.min(prev + 1, list.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
highlightedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && highlightedIndex() >= 0) {
e.preventDefault();
select(list[highlightedIndex()]);
} else if (e.key === "Escape") {
isOpen(false);
}
};
return $.html(
"div",
{
class: "relative w-full",
},
[
ui.Input({
label,
placeholder: placeholder || tt("search")(),
$value: query,
onfocus: () => isOpen(true),
onblur: () => setTimeout(() => isOpen(false), 200),
onkeydown: onKeyDown,
oninput: (e) => {
query(e.target.value);
isOpen(true);
highlightedIndex(-1);
},
...rest,
}),
$.html(
"ul",
{
// AÑADIDO: w-full y box-border para que no se pase ni un píxel
class: "absolute left-0 w-80 max-w-[95vw] menu bg-base-100 rounded-box z-[100] mt-1 p-2 shadow-2xl max-h-60 overflow-y-auto border border-base-300",
style: () => (isOpen() && filtered().length > 0 ? "display: block" : "display: none"),
},
[
ui.For(
filtered,
(opt, i) =>
$.html("li", { class: "w-full" }, [
// li al 100%
$.html(
"a",
{
// AÑADIDO: block w-full para que el azul cubra todo el ancho
class: () => joinClass("block w-full", highlightedIndex() === i && "active bg-primary text-primary-content"),
onclick: () => select(opt),
onmouseenter: () => highlightedIndex(i),
},
typeof opt === "string" ? opt : opt.label,
),
]),
(opt, i) => (typeof opt === "string" ? opt : opt.value) + i,
),
() => (filtered().length === 0 ? $.html("li", { class: "disabled p-2 text-center opacity-50" }, "No hay resultados") : null),
],
),
],
);
};
/** CHECKBOX */ /** CHECKBOX */
ui.CheckBox = (props) => { ui.CheckBox = (props) => {
const { $value, tooltip, toggle, ...rest } = props; const { $value, tooltip, toggle, ...rest } = props;

View File

@@ -51,21 +51,25 @@
return subs; return subs;
}; };
const $ = (initial, key) => { const $ = (initial) => {
if (isObj(initial) && !key && typeof initial !== "function") { if (initial && typeof initial === "object" && !(initial instanceof Node)) {
if (PROXIES.has(initial)) return PROXIES.get(initial); if (PROXIES.has(initial)) return PROXIES.get(initial);
const proxy = new Proxy(initial, { const proxy = new Proxy(initial, {
get(t, p, r) { get(t, p, r) {
track(getPropSubs(t, p)); track(getPropSubs(t, p));
const val = Reflect.get(t, p, r); const val = Reflect.get(t, p, r);
return isObj(val) ? $(val) : val; return val && typeof val === "object" ? $(val) : val;
}, },
set(t, p, v, r) { set(t, p, v, r) {
const old = Reflect.get(t, p, r); const old = Reflect.get(t, p, r);
if (Object.is(old, v)) return true; if (Object.is(old, v)) return true;
const res = Reflect.set(t, p, v, r); const res = Reflect.set(t, p, v, r);
trigger(getPropSubs(t, p)); trigger(getPropSubs(t, p));
if (Array.isArray(t) && p !== "length") trigger(getPropSubs(t, "length")); if (Array.isArray(t) && p !== "length") {
trigger(getPropSubs(t, "length"));
}
return res; return res;
}, },
deleteProperty(t, p) { deleteProperty(t, p) {
@@ -74,29 +78,26 @@
return res; return res;
}, },
}); });
PROXIES.set(initial, proxy); PROXIES.set(initial, proxy);
return proxy; return proxy;
} }
if (typeof initial === "function") { if (typeof initial === "function") {
const subs = new Set(); const subs = new Set();
let cached, let cached;
dirty = true; let dirty = true;
const effect = () => { const effect = () => {
if (effect._deleted) return; if (effect._deleted) return;
effect._cleanups.forEach((c) => c());
effect._cleanups.clear();
effect._deps.forEach((s) => s.delete(effect)); effect._deps.forEach((s) => s.delete(effect));
effect._deps.clear(); effect._deps.clear();
const prev = activeEffect; const prev = activeEffect;
activeEffect = effect; activeEffect = effect;
try { try {
let maxD = 0;
effect._deps.forEach((s) => {
if (s._d > maxD) maxD = s._d;
});
effect.depth = maxD + 1;
subs._d = effect.depth;
const val = initial(); const val = initial();
if (!Object.is(cached, val) || dirty) { if (!Object.is(cached, val) || dirty) {
cached = val; cached = val;
@@ -107,24 +108,22 @@
activeEffect = prev; activeEffect = prev;
} }
}; };
effect._isComputed = true;
effect._deps = new Set(); effect._deps = new Set();
effect._cleanups = new Set(); effect._isComputed = true;
effect._subs = subs; effect._subs = subs;
effect._deleted = false;
effect.markDirty = () => (dirty = true); effect.markDirty = () => (dirty = true);
effect.stop = () => { effect.stop = () => {
effect._deleted = true; effect._deleted = true;
effectQueue.delete(effect);
effect._cleanups.forEach((c) => c());
effect._deps.forEach((s) => s.delete(effect)); effect._deps.forEach((s) => s.delete(effect));
effect._deps.clear();
subs.clear(); subs.clear();
}; };
if (currentOwner) {
currentOwner.cleanups.add(effect.stop); if (currentOwner) currentOwner.cleanups.add(effect.stop);
effect._isComputed = false;
effect();
return () => {};
}
return () => { return () => {
if (dirty) effect(); if (dirty) effect();
track(subs); track(subs);
@@ -132,31 +131,65 @@
}; };
} }
let value = initial;
const subs = new Set(); const subs = new Set();
subs._d = 0;
if (key) {
try {
const s = localStorage.getItem(key);
if (s !== null) initial = JSON.parse(s);
} catch (e) {}
}
return (...args) => { return (...args) => {
if (args.length) { if (args.length) {
const next = typeof args[0] === "function" ? args[0](initial) : args[0]; const next = typeof args[0] === "function" ? args[0](value) : args[0];
if (!Object.is(initial, next)) {
initial = next; if (!Object.is(value, next)) {
if (key) value = next;
try {
localStorage.setItem(key, JSON.stringify(initial));
} catch (e) {}
trigger(subs); trigger(subs);
} }
} }
track(subs); track(subs);
return initial; return value;
}; };
}; };
const $$ = (fn) => {
const effect = () => {
if (effect._deleted) return;
effect._deps.forEach((s) => s.delete(effect));
effect._deps.clear();
effect._cleanups.forEach((c) => c());
effect._cleanups.clear();
const prevEffect = activeEffect;
const prevOwner = currentOwner;
activeEffect = effect;
currentOwner = { cleanups: effect._cleanups };
effect.depth = prevEffect ? prevEffect.depth + 1 : 0;
try {
fn();
} finally {
activeEffect = prevEffect;
currentOwner = prevOwner;
}
};
effect._deps = new Set();
effect._cleanups = new Set();
effect._deleted = false;
effect.stop = () => {
effect._deleted = true;
effectQueue.delete(effect);
effect._deps.forEach((s) => s.delete(effect));
effect._deps.clear();
effect._cleanups.forEach((c) => c());
effect._cleanups.clear();
};
if (currentOwner) currentOwner.cleanups.add(effect.stop);
effect();
return effect.stop;
};
const sweep = (node) => { const sweep = (node) => {
if (node._cleanups) { if (node._cleanups) {
node._cleanups.forEach((f) => f()); node._cleanups.forEach((f) => f());
@@ -205,7 +238,6 @@
el._cleanups = new Set(); el._cleanups = new Set();
for (let [k, v] of Object.entries(props)) { for (let [k, v] of Object.entries(props)) {
// 1. GESTIÓN DE EVENTOS (onchange, onclick...)
if (k.startsWith("on")) { if (k.startsWith("on")) {
const name = k.slice(2).toLowerCase().split(".")[0]; const name = k.slice(2).toLowerCase().split(".")[0];
const mods = k.slice(2).toLowerCase().split(".").slice(1); const mods = k.slice(2).toLowerCase().split(".").slice(1);
@@ -216,12 +248,10 @@
}; };
el.addEventListener(name, handler, { once: mods.includes("once") }); el.addEventListener(name, handler, { once: mods.includes("once") });
el._cleanups.add(() => el.removeEventListener(name, handler)); el._cleanups.add(() => el.removeEventListener(name, handler));
} } else if (k.startsWith("$")) {
else if (k.startsWith("$")) {
const attr = k.slice(1); const attr = k.slice(1);
const stopAttr = $(() => { const stopAttr = $$(() => {
const val = typeof v === "function" ? v() : v; const val = typeof v === "function" ? v() : v;
if (el[attr] === val) return; if (el[attr] === val) return;
@@ -237,11 +267,9 @@
el.addEventListener(evt, h); el.addEventListener(evt, h);
el._cleanups.add(() => el.removeEventListener(evt, h)); el._cleanups.add(() => el.removeEventListener(evt, h));
} }
} } else {
else {
if (typeof v === "function") { if (typeof v === "function") {
const stopAttr = $(() => { const stopAttr = $$(() => {
const val = v(); const val = v();
if (k === "class" || k === "className") el.className = val || ""; if (k === "class" || k === "className") el.className = val || "";
else if (typeof val === "boolean") el.toggleAttribute(k, val); else if (typeof val === "boolean") el.toggleAttribute(k, val);
@@ -262,7 +290,7 @@
const marker = document.createTextNode(""); const marker = document.createTextNode("");
el.appendChild(marker); el.appendChild(marker);
let nodes = []; let nodes = [];
const stopList = $(() => { const stopList = $$(() => {
const res = c(); const res = c();
const next = (Array.isArray(res) ? res : [res]).map((i) => const next = (Array.isArray(res) ? res : [res]).map((i) =>
i?._isRuntime ? i.container : i instanceof Node ? i : document.createTextNode(i ?? ""), i?._isRuntime ? i.container : i instanceof Node ? i : document.createTextNode(i ?? ""),
@@ -298,15 +326,12 @@
const outlet = Div({ class: "router-outlet" }); const outlet = Div({ class: "router-outlet" });
let current = null; let current = null;
// ESTE ES EL ÚNICO EFECTO: Solo observa sPath $$(() => {
$(() => { const path = sPath();
const path = sPath(); // Suscripción a la URL
// 1. Limpieza total de la página anterior
if (current) current.destroy(); if (current) current.destroy();
outlet.innerHTML = ""; outlet.innerHTML = "";
// 2. Buscamos la ruta
const parts = path.split("/").filter(Boolean); const parts = path.split("/").filter(Boolean);
const route = const route =
routes.find((r) => { routes.find((r) => {
@@ -323,9 +348,6 @@
if (p.startsWith(":")) params[p.slice(1)] = parts[i]; if (p.startsWith(":")) params[p.slice(1)] = parts[i];
}); });
// 3. EJECUCIÓN AISLADA
// Usamos $.ignore para que el Router NO se suscriba a las señales de Home()
// Pero el $.view permite que Home() sea reactivo internamente.
current = $.ignore(() => current = $.ignore(() =>
$.view(() => { $.view(() => {
const res = route.component(params); const res = route.component(params);
@@ -360,5 +382,6 @@
window[t.charAt(0).toUpperCase() + t.slice(1)] = (p, c) => $.html(t, p, c); window[t.charAt(0).toUpperCase() + t.slice(1)] = (p, c) => $.html(t, p, c);
}); });
window.$ = $; window.$ = $;
window.$$ = $$;
})(); })();
export const { $ } = window; export const { $, $$ } = window;