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.