10 KiB
CSS y JavaScript — Convenciones del Módulo
Este documento define cómo escribir CSS, JavaScript y, cuando hace falta, Vue 3 dentro de un módulo Acai. Cubre la regla "Tailwind first" + BEM para CSS custom, las clases utilitarias propias de Acai (transition3s, click-a-child, line-clamp2, lazyload, bg-main-color, etc.), las CSS variables del tema (--main-color), el patrón obligatorio de scoping vía la clase raíz del módulo, la regla dura de que script.js y style.css son archivos estáticos (sin Twig dentro), cómo pasar valores dinámicos desde index-base.tpl a JS vía data-*, cuándo usar Vue 3 y cómo integrarlo evitando conflicto de delimiters con Twig, y los componentes nativos del builder (Carousel c-tns-wrapper, Lightbox, Breadcrumb, AOS, Lazy loading). Léelo antes de escribir cualquier style.css o script.js.
Estructura del módulo
- Cada módulo genera HTML + CSS + JS (y opcionalmente Vue 3).
- Define una clase raíz en kebab-case específica del módulo:
product-card,hero-section,buscador-apartados. - Todo el CSS y JS deben quedar scopeados bajo esa clase raíz.
CSS
Tailwind first
Usa TailwindCSS como método principal. Reserva CSS custom solo cuando Tailwind no cubra el caso (estados complejos, transiciones específicas, animaciones).
<div class="flex items-center gap-4 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-gray-900">Title</h2>
</div>
BEM para CSS custom
Cuando necesites CSS propio, scopealo bajo la clase raíz con BEM:
.hero-section { }
.hero-section__title { }
.hero-section__image { }
.hero-section--dark { }
Nunca uses clases globales sin prefijo de módulo.
CSS variables del tema
| Variable | Descripción |
|---|---|
var(--main-color) |
Color de marca principal |
var(--main-color-light) |
Variante clara |
var(--main-color-dark) |
Variante oscura |
Estilos inline con fallbacks
Patrón estándar para colores configurables por el usuario (variables del builder):
<div style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<p style="color: {{ colordeltexto ? colordeltexto : '#111827' }}">
Clases utilitarias de Acai
| Clase | Descripción |
|---|---|
transition3s |
Transición suave 0.3s |
click-a-child |
Hace al padre clickeable vía el primer <a> hijo |
line-clamp2 / line-clamp3 / line-clamp5 |
Truncar texto a N líneas |
filter-white |
Filtro CSS para teñir imágenes/iconos en blanco |
lazyload |
Activa lazy loading (usar con data-src) |
text-shadow |
Sombra de texto para legibilidad sobre imágenes |
wysiwyg |
Wrapper para contenido rico (estilos coherentes) |
bg-main-color / bg-main-color-light / bg-main-color-dark |
Fondos con color primario |
text-main-color / text-main-color-light / text-main-color-dark |
Texto con color primario |
titulo-main-color / titulo-white |
Estilos de título resaltado |
c-tns-wrapper / c-tns-container / c-tns-nav-container |
Carousel built-in |
glightbox |
Activa lightbox |
Reglas para style.css
style.css es estático. Reglas duras:
- NO uses sintaxis Twig (
{{ ... }},{% ... %}) ni atributos builder (c-if,c-for). - NO escribas selectores que dependan de
{{ section_id }}— scopea con la clase raíz del módulo.
/* CORRECTO — scopeado con clase raíz */
.product-card { }
.product-card__title { color: var(--main-color); }
/* INCORRECTO — Twig inside */
#{{ section_id }} h3 { }
JavaScript
script.js es estático
Igual que style.css: NO uses sintaxis Twig dentro. Si necesitas valores dinámicos, pásalos desde index-base.tpl vía atributos data-*.
Patrón correcto
index-base.tpl:
<section class="buscador-apartados"
id="{{ section_id }}"
data-section-id="{{ section_id }}"
data-hook-endpoint="/hooks/buscadorapartados_hjd8s/"
data-limit="{{ limite | default('10') }}">
<input class="buscador-apartados__input" type="text">
<ul class="buscador-apartados__results"></ul>
</section>
script.js:
document.querySelectorAll('.buscador-apartados').forEach((section) => {
const sectionId = section.dataset.sectionId;
const hookEndpoint = section.dataset.hookEndpoint;
const limit = parseInt(section.dataset.limit, 10);
const input = section.querySelector('.buscador-apartados__input');
const results = section.querySelector('.buscador-apartados__results');
input.addEventListener('input', (e) => {
CmsApi.hook(hookEndpoint, { termino: e.target.value, limite: limit }, (data) => {
results.innerHTML = data.items.map((it) => `<li>${it.titulo}</li>`).join('');
});
});
});
NUNCA hagas CmsApi.hook('/hooks/{{ moduleId }}/', ...) dentro de script.js. Si necesitas el endpoint, pásalo por data-hook-endpoint.
CmsApi (cliente)
// Llamar hook
CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, (response) => {
console.log(response);
});
// Si llamas un hook propio del módulo, usa el module-id real:
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, (response) => {
console.log(response);
});
Embeber <script> directamente — NO
No embebas <script> con lógica del módulo dentro de index-base.tpl. La lógica vive en script.js. La excepción: scripts mínimos para inicializar Vue 3 (ver más abajo) que sí necesitan los delimitadores {{ section_id }} para mountear sobre IDs únicos.
Cuándo usar Vue 3
Usa Vue 3 (vía CDN) cuando la lógica requiera:
- Doble binding / reactividad
- Solicitudes asíncronas complejas con UI reactiva
- Componentes reutilizables dentro del módulo
- Gestión de estado local
- Ciclos de vida (mounted, unmounted, etc.)
Para lógica simple (listeners, fetch único, toggle de clases), usa JavaScript vanilla.
Integración Vue 3
<div id="app-{{ section_id }}">
<p>${ message }</p>
<button @click="increment">${ count }</button>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
delimiters: ['${', '}'], // OBLIGATORIO — evita conflicto con Twig {{ }}
setup() {
const message = ref('Hello');
const count = ref(0);
const increment = () => count.value++;
return { message, count, increment };
}
}).mount('#app-{{ section_id }}');
</script>
Reglas:
- Siempre redefine
delimitersa['${', '}']para no chocar con Twig. - Mountea sobre un id único usando
section_id:#app-{{ section_id }}. - Vue se carga como librería global vía
add_global_libraryo ya incluida en el proyecto (ver08-layout-and-libraries.md).
Variables globales disponibles en JS / Twig
| Variable | Descripción | Ejemplo |
|---|---|---|
section_id |
ID único por instancia del módulo | <div id="{{section_id}}"> |
server.HTTP_HOST |
Dominio actual | https://{{ server.HTTP_HOST }}/path |
loop.index |
Índice 1-based en c-for/{% for %} |
{{ loop.index }} |
loop.index is odd / is even |
Para layouts alternados | zigzag |
interno |
true dentro del editor CMS |
c-class="{'editor-mode': interno}" |
Patrón section_id
Cada instancia de un módulo recibe un section_id único. Úsalo para anchors, IDs HTML y selector raíz de JS:
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative">
<!-- contenido -->
</section>
Componentes nativos
Carousel — c-tns-wrapper
<div class="c-tns-wrapper"
data-responsive='{"0":1,"768":2,"1024":3}'
data-autoplay-timeout="5000"
data-mode="carousel"
data-speed="400"
data-nav="true">
<ul class="c-tns-container">
<li data-field-type="multiv2" data-field-label="Slides" class="px-2">
<!-- contenido del slide -->
</li>
</ul>
</div>
| Atributo | Descripción |
|---|---|
data-responsive |
Items por breakpoint. JSON {"0":1,"768":2,"1024":3} o sintaxis corta "sm:2, md:3, lg:4" |
data-autoplay-timeout |
Intervalo autoplay (ms) |
data-mode |
"gallery" o "carousel" |
data-speed |
Velocidad de transición (ms) |
data-nav |
"true" para mostrar dots |
Dots de navegación custom:
<div class="c-tns-nav-container absolute bottom-4 left-0 w-full flex justify-center items-end z-20">
<div c-for="item in records"
class="pointer-events-auto cursor-pointer rounded-full border-2 border-white w-4 h-4 mx-1 bg-black bg-opacity-50">
</div>
</div>
Lightbox
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}">
</a>
Breadcrumb
<breadCrumb class="bg-gray-200 p-3 rounded" c-prevlinks="null"></breadCrumb>
Animate On Scroll (AOS)
<div data-aos="fade-up" data-aos-duration="800">Contenido</div>
Valores comunes: fade-up, fade-down, fade-left, fade-right, zoom-in, zoom-in-up, fade-up-right, fade-up-left.
Tras cambios dinámicos en JS:
AOS.refresh();
Lazy loading
<!-- Builder var con lazy automático -->
<img data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-width="800" alt="">
<!-- Manual en plantillas -->
<img class="lazyload" data-src="{{ record.imagen[0].urlPath | imagec(800) }}" alt="">
Reglas críticas
script.jsystyle.cssson estáticos — sin sintaxis Twig dentro.- Para valores dinámicos en JS, pásalos desde
index-base.tplvía atributosdata-*. - Define una clase raíz en kebab-case por módulo. Scopea TODO el CSS/JS bajo ella.
- Tailwind first; CSS custom solo donde Tailwind no llegue, siempre con BEM.
- Vue 3: redefine
delimiters: ['${', '}']para evitar conflicto con Twig. - Mountea Vue sobre
#app-{{ section_id }}. - Usa las clases utilitarias de Acai (
transition3s,lazyload,bg-main-color, etc.) antes de inventar utilidades. - NO embebas lógica
<script>dentro deindex-base.tpl(Vue init es la única excepción común).