Recetas JS
El CSS del DS es intencionalmente tonto: define estados como .is-active o .is-collapsed, pero algo tiene que alternarlos. Estas recetas en JavaScript puro (sin framework) cubren los cuatro comportamientos estándar. Copialas a tu app tal cual, o adaptalas a tu framework (Stimulus, etc.) — el contrato es la clase de estado, no el código.
Tabs
Alterna .is-active dentro de un .cl-tabs y emite cl-tabs:change. Solo intercepta anchors con #hash (o <button>); la navegación real pasa de largo.
document.querySelectorAll(".cl-tabs").forEach(tabs => {
tabs.addEventListener("click", event => {
const tab = event.target.closest(".cl-tab");
if (!tab) return;
const href = tab.getAttribute("href");
if (href && !href.startsWith("#")) return; // navegación real
if (href) event.preventDefault();
tabs.querySelectorAll(".cl-tab").forEach(t => t.classList.toggle("is-active", t === tab));
tabs.dispatchEvent(new CustomEvent("cl-tabs:change", { detail: { href }, bubbles: true }));
});
});
Banner / flash descartable
Cierra el banner al hacer clic en el botón con data-dismiss, o solo después de data-timeout milisegundos. Agrega .is-dismissing antes de remover, por si el banner define una transición de salida.
<div class="cl-banner cl-banner--positive" data-timeout="4000">
<div class="cl-banner-body">Cambios guardados.</div>
<div class="cl-banner-actions">
<button class="cl-btn cl-btn--ghost cl-btn--sm cl-btn--icon" data-dismiss aria-label="Cerrar">…</button>
</div>
</div>
(El SVG del botón de cerrar está en la página de banners.)
document.querySelectorAll(".cl-banner").forEach(banner => {
const dismiss = () => {
banner.classList.add("is-dismissing");
banner.addEventListener("transitionend", () => banner.remove(), { once: true });
setTimeout(() => banner.remove(), 300); // fallback sin transición
};
banner.querySelector("[data-dismiss]")?.addEventListener("click", dismiss);
const timeout = Number(banner.dataset.timeout || 0);
if (timeout > 0) setTimeout(dismiss, timeout);
});
Autosubmit con debounce
Envía el formulario más cercano cuando cambia un filtro (select, búsqueda), con un debounce de 250 ms para no disparar un request por tecla.
<form data-autosubmit>
<input class="cl-input" type="search" name="q" placeholder="Buscar…">
<select class="cl-input" name="status">…</select>
</form>
document.querySelectorAll("form[data-autosubmit]").forEach(form => {
let timer;
const submit = () => {
clearTimeout(timer);
timer = setTimeout(() => form.requestSubmit(), 250);
};
form.addEventListener("input", submit); // texto
form.addEventListener("change", submit); // selects, checkboxes
});
Header de objeto sticky
Colapsa el .cl-object-head al pasar el umbral de scroll (agrega .is-collapsed) y mantiene actualizado el offset de las .cl-tabs--sticky que van debajo.
(() => {
const head = document.querySelector(".cl-object-head");
if (!head) return;
// Centinela un pixel encima del cuerpo: cuando sale del viewport,
// el usuario cruzó el umbral.
const sentinel = document.createElement("div");
sentinel.style.cssText = "position:absolute;top:0;height:1px;width:1px;";
head.parentNode.insertBefore(sentinel, head.nextSibling);
new IntersectionObserver(([e]) => {
head.classList.toggle("is-collapsed", !e.isIntersecting);
}, { rootMargin: "-1px 0px 0px 0px", threshold: [1] }).observe(sentinel);
// Si hay tabs sticky bajo el head, publicá la altura del head
// como CSS var para que sepan cuánto bajar.
const page = head.closest(".cl-object-page");
const setH = () => page?.style.setProperty(
"--cl-tabs-stick-offset",
`calc(var(--h-shell) + ${head.offsetHeight}px)`
);
setH();
new ResizeObserver(setH).observe(head);
})();
Con Turbo/Hotwire, corré estas recetas en turbo:load (o envolvelas en un controller de Stimulus) para que apliquen después de cada navegación.