Editor y toolbar: jerarquía, tarjetas legibles, foco seguro y autoPulsar estable

- Toolbar: «Nueva demo» primaria junto a la marca (N); Tema como icono;
  «Volver al ejemplo Café Aurora» (peligro) solo dentro del menú «⋯»;
  etiquetas de Nueva demo y Presentación visibles entre 900 y 1100 px.
- Tarjetas: flecha de dirección en el número (↙/↗), badge con icono
  (solo icono en editor estrecho), resumen en 2.ª línea al abrir, hueco
  de ↑/↓ reservado (sin saltos al pasar el ratón), container queries.
- Borrar (mensaje, botón, fila, tarjeta, respuesta) nunca deja el foco
  en otro botón ✕.
- autoPulsar sigue al elemento al mover/borrar/arrastrar; opción
  inexistente visible y aviso; casilla «Se pulsa sola» por elemento,
  chip «▶ auto» y select arriba del formulario.
- Deshacer/rehacer conservan el tema; el tema marca «Guardando…».
- Escenarios: siempre punto de deshacer, un único aviso con acción y
  foco al nombre de la cuenta; chips de tipos en el diálogo Demos,
  rejilla de 4 columnas en >= 1280 y sin nombre precargado.
- Paleta sin estado seleccionado, «+» en hover/foco, chip de destino
  y «+ Otro» solo tras el primer alta.
- Avisos abajo al centro sobre la vista previa; tel: válido en
  plantillas (máx. 1); interruptor «Mostrar cabecera» fijo (role=switch).
- Teclado: enlace «Saltar a la vista previa» y G / F6; atajos con ⌘ en
  Mac; etiqueta «Velocidad»; «Repetir desde el inicio».
- Demo vacía: herramientas ocultas y pista dentro del chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
urieljareth
2026-09-27 21:32:22 -06:00
co-authored by Claude Opus 5.5
parent 9a5f21badf
commit 3509a77d62
6 changed files with 762 additions and 134 deletions
+203 -34
View File
@@ -5,7 +5,8 @@
· Indicador de guardado en la toolbar (#estado-guardado)
· Confirmación de Restablecer / Importar (WAM.confirmarReemplazo)
· Diálogo «Demos»: escenarios por industria + demos guardadas (wam-demos)
· Atajos de teclado globales y diálogo de ayuda (?)
· Atajos de teclado globales y diálogo de ayuda (?); G / F6 y el enlace
«Saltar a la vista previa» mueven el foco entre editor y teléfono
· Pestañas Editor / Vista previa en ventana estrecha y menú «⋯»
· Enlace vista previa → tarjeta (Alt+clic o doble clic en una burbuja)
· Región sr-only que anuncia los mensajes nuevos del chat
@@ -137,10 +138,17 @@
WAM.puedeDeshacer = function () { return pilaDeshacer.length > 0; };
// El tema es una preferencia de visualización, no contenido: deshacer y
// rehacer conservan el tema actual en vez del de la instantánea.
function conTemaActual(x) {
if (x && WAM.estado) x.tema = WAM.estado.tema;
return x;
}
WAM.deshacer = function () {
if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; }
pilaRehacer.push(clonar(WAM.estado));
sustituirEstado(pilaDeshacer.pop());
sustituirEstado(conTemaActual(pilaDeshacer.pop()));
pintarBotonesHistorial();
toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } });
return true;
@@ -149,7 +157,7 @@
WAM.rehacer = function () {
if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; }
pilaDeshacer.push(clonar(WAM.estado));
sustituirEstado(pilaRehacer.pop());
sustituirEstado(conTemaActual(pilaRehacer.pop()));
pintarBotonesHistorial();
toast("Cambio rehecho");
return true;
@@ -253,14 +261,17 @@
if (!dentro) dlg.close();
});
var foco = null;
var restaurar = true;
dlg.addEventListener("close", function () {
setTimeout(function () { dlg.remove(); }, 0);
if (foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
if (restaurar && foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
});
document.body.appendChild(dlg);
return {
dlg: dlg, cuerpo: cuerpo, pie: pie,
abrir: function () { foco = document.activeElement; dlg.showModal(); }
abrir: function () { foco = document.activeElement; dlg.showModal(); },
// La acción elegida mueve el foco a otro sitio (p. ej. el nombre de la cuenta)
sinRestaurar: function () { restaurar = false; }
};
}
@@ -281,7 +292,7 @@
lsDel(CLAVE_ACTIVA);
ejecutar();
marcarEditado();
toast("Demo de ejemplo restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
toast("Demo de ejemplo «Café Aurora» restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
} else {
ejecutar(); // el punto de deshacer lo toma importarJSON al aplicar el archivo
}
@@ -290,11 +301,11 @@
var nombre = (WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "sin nombre";
var resumen = "«" + nombre + "», " + nMensajes() + " mensaje" + (nMensajes() === 1 ? "" : "s");
if (!soportaDialog) {
if (window.confirm(esReset ? "¿Restablecer la demo de ejemplo? Se sustituirá " + resumen + "."
if (window.confirm(esReset ? "¿Volver al ejemplo Café Aurora? Se sustituirá " + resumen + "."
: "¿Importar un JSON? Se sustituirá " + resumen + ".")) hacer();
return;
}
var d = crearDialogo("dlg-confirmar", esReset ? "¿Restablecer la demo de ejemplo?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
var d = crearDialogo("dlg-confirmar", esReset ? "¿Volver al ejemplo Café Aurora?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
d.cuerpo.appendChild(h("p", { text: (esReset
? "La demo actual (" + resumen + ") se sustituirá por la demo de ejemplo «Café Aurora»."
: "El archivo que elijas sustituirá la demo actual (" + resumen + ").") }));
@@ -372,14 +383,59 @@
return true;
}
/* Tras cargar un escenario lo primero es poner el nombre del cliente:
se despliega «Cuenta» y se enfoca (y selecciona) su nombre. */
function enfocarNombreCuenta() {
if (esEstrecha() && document.body.classList.contains("vista-movil")) return;
if (document.body.classList.contains("presentacion")) return;
var det = porId("bloque-cuenta");
if (det && !det.open) det.open = true;
var inp = porId("cuenta-nombre");
if (!inp) return;
try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); }
if (inp.scrollIntoView) inp.scrollIntoView({ block: "nearest" });
if (typeof inp.select === "function") inp.select();
}
/* Siempre deja un punto de deshacer (también desde una demo vacía) y un
único aviso con «Deshacer»: WAM.toast retira los avisos con acción
anteriores, que ya no deshacen lo que dicen. */
function aplicarDemoCompleta(crudo, texto) {
WAM.puntoDeshacer();
sustituirEstado(normalizado(crudo));
lsDel(CLAVE_ACTIVA);
toast(texto, { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
}
function cargarEscenario(esc) {
var crudo = WAM.datosEscenario ? WAM.datosEscenario(esc.id) : null;
if (!crudo) return;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(crudo));
lsDel(CLAVE_ACTIVA);
toast("Escenario «" + esc.nombre + "» cargado", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
aplicarDemoCompleta(crudo, "Escenario «" + esc.nombre + "» cargado. Cambia el nombre del negocio en «Cuenta».");
enfocarNombreCuenta();
}
// Tipos de mensaje que usa un escenario (chips del diálogo Demos)
var NOMBRE_TIPO = {
text: "Texto", image: "Imagen", video: "Vídeo", audio: "Audio", document: "Documento",
sticker: "Sticker", location: "Ubicación", contact: "Contacto", template: "Plantilla",
buttons: "Botones", list: "Lista", cta_url: "Botón URL", carousel: "Carrusel"
};
// Los más distintivos primero (texto casi siempre está)
var ORDEN_TIPOS = ["template", "list", "carousel", "buttons", "cta_url", "location", "document",
"contact", "image", "video", "audio", "sticker", "text"];
function chipsTipos(mensajes) {
var usados = Object.create(null);
(mensajes || []).forEach(function (m) { if (m && m.tipo) usados[m.tipo] = true; });
var lista = ORDEN_TIPOS.filter(function (t) { return usados[t]; });
if (!lista.length) return null;
var max = 4;
var cont = h("span", { class: "wam-esc-tipos", "aria-label": "Incluye: " + lista.map(function (t) { return NOMBRE_TIPO[t]; }).join(", ") });
lista.slice(0, max).forEach(function (t) {
cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: NOMBRE_TIPO[t] || t }));
});
if (lista.length > max) cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: "+" + (lista.length - max) }));
return cont;
}
function fechaCorta(iso) {
@@ -397,29 +453,28 @@
// --- Escenarios ---
var gridEsc = h("div", { class: "wam-esc-grid" });
(WAM.ESCENARIOS || []).forEach(function (esc) {
var n = 0;
try { n = (esc.datos().mensajes || []).length; } catch (e) { n = 0; }
var ms = [];
try { ms = esc.datos().mensajes || []; } catch (e) { ms = []; }
var b = h("button", { type: "button", class: "wam-esc", "data-esc": esc.id }, [
h("span", { class: "wam-esc-nombre", text: esc.nombre }),
h("span", { class: "wam-esc-desc", text: esc.descripcion }),
h("span", { class: "wam-esc-meta", text: n ? n + " mensajes" : "Vacía" })
chipsTipos(ms),
h("span", { class: "wam-esc-meta", text: ms.length ? ms.length + " mensajes" : "Vacía" })
]);
b.addEventListener("click", function () { d.dlg.close(); cargarEscenario(esc); });
b.addEventListener("click", function () { d.sinRestaurar(); d.dlg.close(); cargarEscenario(esc); });
gridEsc.appendChild(b);
});
var bAurora = h("button", { type: "button", class: "wam-esc" }, [
var msAurora = [];
try { msAurora = (typeof WAM.datosDemo === "function" && WAM.datosDemo().mensajes) || []; } catch (e) { msAurora = []; }
var bAurora = h("button", { type: "button", class: "wam-esc", "data-esc": "aurora" }, [
h("span", { class: "wam-esc-nombre", text: "Catálogo · Café Aurora" }),
h("span", { class: "wam-esc-desc", text: "Los 13 tipos de mensaje de la Cloud API. Útil como referencia." }),
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" })
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" + (msAurora.length ? " · " + msAurora.length + " mensajes" : "") })
]);
bAurora.addEventListener("click", function () {
d.dlg.close();
if (typeof WAM.datosDemo !== "function") return;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(WAM.datosDemo()));
lsDel(CLAVE_ACTIVA);
toast("Demo «Café Aurora» cargada", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
aplicarDemoCompleta(WAM.datosDemo(), "Demo «Café Aurora» cargada");
});
gridEsc.appendChild(bAurora);
@@ -432,13 +487,17 @@
// --- Demos guardadas ---
var lista = h("ul", { class: "wam-demos-lista" });
var activa = demoActiva();
// Sin nombre precargado: el consultor escribe el del cliente (si lo deja
// vacío se usa el nombre de la cuenta).
var nombreCuenta = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "";
var inputNombre = h("input", {
type: "text", class: "wam-dlg-input", id: "dlg-demo-nombre", maxlength: "60",
value: activa ? activa.nombre : ((WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "Mi demo"),
value: activa ? activa.nombre : "",
placeholder: "Nombre de la demo (p. ej. el cliente)" + (nombreCuenta ? " · si lo dejas vacío: «" + nombreCuenta + "»" : ""),
"aria-label": "Nombre de la demo"
});
var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () {
var nombre = String(inputNombre.value || "").trim() || "Mi demo";
var nombre = String(inputNombre.value || "").trim() || nombreCuenta || "Mi demo";
var misma = activa && activa.nombre === nombre;
var e = guardarDemoComo(nombre, misma ? activa.id : null);
if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); }
@@ -539,27 +598,43 @@
/* ------------------------------------------------------------------
Atajos de teclado
------------------------------------------------------------------ */
// «Ctrl» se pinta como ⌘ en Mac (los atajos aceptan ctrlKey o metaKey)
var ATAJOS = [
["N", "Nueva demo: escenarios y demos guardadas"],
["P", "Modo presentación"],
["Espacio", "Reproducir / detener la demo"],
["G", "Saltar entre el editor y el teléfono (también F6)"],
["T", "Tema claro / oscuro"],
["F", "Pantalla completa"],
["N", "Demos y escenarios"],
["Esc", "Cerrar la lista del chat o salir de la presentación"],
["Ctrl + S", "Exportar JSON"],
["Ctrl + Z", "Deshacer (fuera de un campo de texto)"],
["Ctrl + Mayús + Z", "Rehacer"],
["Alt + ↑ / ↓", "Mover el mensaje o elemento con el foco"],
["Alt + clic en una burbuja", "Abrir su tarjeta en el editor (también doble clic)"],
["Alt + clic", "Sobre una burbuja: abre su tarjeta en el editor (también doble clic)"],
["?", "Esta ayuda"]
];
function esMac() {
var nav = window.navigator || {};
var plat = (nav.userAgentData && nav.userAgentData.platform) || nav.platform || nav.userAgent || "";
return /mac|iphone|ipad|ipod/i.test(String(plat));
}
function teclaVisible(k) {
if (!esMac()) return k;
if (k === "Ctrl") return "⌘";
if (k === "Alt") return "⌥";
if (k === "Mayús") return "⇧";
return k;
}
WAM.abrirAtajos = function () {
if (!soportaDialog) return;
var d = crearDialogo("dlg-atajos", "Atajos de teclado", "wam-dlg-chico");
var tabla = h("dl", { class: "wam-atajos" });
ATAJOS.forEach(function (a) {
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(function (k, i, arr) {
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(teclaVisible).map(function (k, i, arr) {
var frag = document.createDocumentFragment();
frag.appendChild(h("kbd", { text: k }));
if (i < arr.length - 1) frag.appendChild(document.createTextNode(" + "));
@@ -591,12 +666,66 @@
return !!(s && !s.hidden && s.offsetParent !== null);
}
/* ------------------------------------------------------------------
Saltar entre paneles: enlace «Saltar a la vista previa» y G / F6
------------------------------------------------------------------ */
var ultimoFocoEditor = null;
function enfocar(el) {
if (!el) return false;
try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); }
return document.activeElement === el;
}
function irAVistaPrevia() {
if (esEstrecha() && !document.body.classList.contains("presentacion")) WAM.setVistaMovil("vista");
var cuerpo = porId("chat-cuerpo");
if (!cuerpo) return;
// tras el cambio de pestaña (display) el nodo ya es enfocable
requestAnimationFrame(function () { enfocar(cuerpo); });
}
function irAlEditor() {
if (document.body.classList.contains("presentacion")) return false;
if (esEstrecha()) WAM.setVistaMovil("editor");
requestAnimationFrame(function () {
var lista = porId("lista-mensajes");
var previo = ultimoFocoEditor && ultimoFocoEditor.isConnected && ultimoFocoEditor.getClientRects().length ? ultimoFocoEditor : null;
if (previo && enfocar(previo)) return;
var cab = lista && lista.querySelector(".ed-cab-principal");
if (cab && enfocar(cab)) { if (cab.scrollIntoView) cab.scrollIntoView({ block: "nearest" }); return; }
var pal = document.querySelector('#nuevo-tipo [data-tipo][tabindex="0"]') || document.querySelector("#nuevo-tipo [data-tipo]");
enfocar(pal || lista);
});
return true;
}
function alternarPanel() {
var movil = porId("panel-movil");
var a = document.activeElement;
if (movil && a && movil.contains(a)) irAlEditor();
else irAVistaPrevia();
}
function enlazarSaltos() {
var enlace = porId("saltar-vista");
if (enlace) enlace.addEventListener("click", function (e) { e.preventDefault(); irAVistaPrevia(); });
var ed = porId("panel-editor");
if (ed) ed.addEventListener("focusin", function (e) { ultimoFocoEditor = e.target; });
}
function alTecla(e) {
if (e.defaultPrevented || e.isComposing) return;
var k = e.key;
var mod = e.ctrlKey || e.metaKey;
var hayDialogo = !!document.querySelector("dialog[open]");
if (k === "F6" && !mod && !e.altKey && !hayDialogo) {
e.preventDefault();
alternarPanel();
return;
}
if (mod && !e.altKey && (k === "s" || k === "S")) {
e.preventDefault();
if (!hayDialogo && typeof WAM.exportarJSON === "function") WAM.exportarJSON();
@@ -625,6 +754,7 @@
case "t": case "T": e.preventDefault(); clicEn("btn-tema"); break;
case "f": case "F": e.preventDefault(); alternarPantallaCompleta(); break;
case "n": case "N": e.preventDefault(); WAM.abrirDemos(); break;
case "g": case "G": e.preventDefault(); alternarPanel(); break;
case "?": e.preventDefault(); WAM.abrirAtajos(); break;
case " ":
if (esInteractivo(e.target)) return; // Espacio activa el botón con foco
@@ -638,15 +768,28 @@
/* ------------------------------------------------------------------
Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista
------------------------------------------------------------------ */
function cerrarMenu() {
function cerrarMenu(devolverFoco) {
var tb = porId("toolbar");
var mas = porId("btn-mas");
if (!tb || !tb.classList.contains("tb-menu-abierto")) return false;
var menu = porId("tb-menu");
var dentro = menu && menu.contains(document.activeElement);
tb.classList.remove("tb-menu-abierto");
if (mas) mas.setAttribute("aria-expanded", "false");
if (mas) {
mas.setAttribute("aria-expanded", "false");
if (devolverFoco !== false && dentro) mas.focus();
}
return true;
}
// Opciones visibles del menú «⋯» abierto (en escritorio solo «Volver al ejemplo»)
function opcionesMenu() {
var menu = porId("tb-menu");
if (!menu) return [];
var bs = esEstrecha() ? menu.querySelectorAll("button:not([disabled])") : menu.querySelectorAll("#btn-reset");
return Array.prototype.filter.call(bs, function (b) { return b.getClientRects().length > 0; });
}
function enlazarMenu() {
var tb = porId("toolbar"), mas = porId("btn-mas"), menu = porId("tb-menu");
if (!tb || !mas || !menu) return;
@@ -654,10 +797,27 @@
e.stopPropagation();
var abierto = tb.classList.toggle("tb-menu-abierto");
mas.setAttribute("aria-expanded", abierto ? "true" : "false");
if (abierto) { var b = menu.querySelector("button:not([disabled])"); if (b) b.focus(); }
if (abierto) { var ops = opcionesMenu(); if (ops[0]) ops[0].focus(); }
});
// Flechas dentro del menú abierto
tb.addEventListener("keydown", function (e) {
if (!tb.classList.contains("tb-menu-abierto") || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
var ops = opcionesMenu();
var i = ops.indexOf(document.activeElement);
if (i < 0 && document.activeElement !== mas) return;
e.preventDefault();
var j = i < 0 ? 0 : (i + (e.key === "ArrowDown" ? 1 : -1) + ops.length) % ops.length;
if (ops[j]) ops[j].focus();
});
// Al salir con Tab del menú abierto, se cierra
tb.addEventListener("focusout", function (e) {
if (!tb.classList.contains("tb-menu-abierto")) return;
var sig = e.relatedTarget;
if (sig && (menu.contains(sig) || sig === mas)) return;
if (sig) cerrarMenu(false);
});
menu.addEventListener("click", function (e) {
if (e.target.closest && e.target.closest("button")) cerrarMenu();
if (e.target.closest && e.target.closest("button")) cerrarMenu(false);
});
document.addEventListener("click", function (e) {
if (!tb.classList.contains("tb-menu-abierto")) return;
@@ -782,9 +942,17 @@
/* ------------------------------------------------------------------
Estado de los botones de la toolbar
------------------------------------------------------------------ */
var temaPintado = null;
function sincronizarTema() {
var tema = document.body.getAttribute("data-tema");
var b = porId("btn-tema");
if (b) b.setAttribute("aria-pressed", document.body.getAttribute("data-tema") === "oscuro" ? "true" : "false");
if (b) {
b.setAttribute("aria-pressed", tema === "oscuro" ? "true" : "false");
b.setAttribute("aria-label", "Tema oscuro");
}
// El cambio de tema (botón o T) también pasa por «Guardando…»
if (temaPintado !== null && tema !== temaPintado) marcarEditado();
temaPintado = tema;
}
/* ------------------------------------------------------------------
@@ -809,6 +977,7 @@
enlazarPestanas();
observarToolbar();
enlazarChat();
enlazarSaltos();
envolverAnuncios();
sincronizarTema();