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

@@ -51,7 +51,7 @@ export const UI = ($, defaultLang = "es") => {
const container = $.html("div", { style: "display:contents" }, [marker]);
const cache = new Map();
$(() => {
$$(() => {
const items = val(source) || [];
const newKeys = new Set();
@@ -181,32 +181,54 @@ export const UI = ($, defaultLang = "es") => {
};
/** INPUT */
ui.Input = (props) => {
const { label, tip, $value, $error, isSearch, ...rest } = props;
ui.Input = (props) => {
const { label, tip, $value, $error, isSearch, icon, ...rest } = props;
const inputEl = $.html("input", {
...rest,
placeholder: props.placeholder || (isSearch ? tt("search") : " "),
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),
});
// Estado local para alternar visibilidad si es password
const isPassword = props.type === "password";
const showPassword = $(false);
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" }, [
label ? $.html("span", {}, label) : null,
tip ? $.html("div", { class: "tooltip tooltip-right", "data-tip": tip }, $.html("span", { class: "badge badge-ghost badge-xs" }, "?")) : null,
inputEl,
() => (val($error) ? $.html("span", { class: "text-error text-xs" }, val($error)) : null),
]);
};
return $.html("label", {
class: "input input-bordered floating-label flex items-center gap-2 w-full relative"
}, [
// 1. Icono Izquierda (opcional)
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 */
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]);
};
/** 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 */
ui.CheckBox = (props) => {
const { $value, tooltip, toggle, ...rest } = props;