diff --git a/css/whatsapp.css b/css/whatsapp.css index f832e31..23536dd 100644 --- a/css/whatsapp.css +++ b/css/whatsapp.css @@ -769,6 +769,7 @@ -webkit-tap-highlight-color: transparent; } .wa .sheet-fila:active { background: rgba(0, 0, 0, .05); } +.wa .sheet-fila.qr-btn-inactiva:active { background: none; } .wa .sheet-fila-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; } .wa .sheet-fila-titulo { font-size: 17px; line-height: 22px; } .wa .sheet-fila-desc { font-size: 15px; line-height: 18px; letter-spacing: 0; color: var(--wa-sheet-sub); } diff --git a/js/render.js b/js/render.js index dfa5f61..115d10a 100644 --- a/js/render.js +++ b/js/render.js @@ -30,6 +30,8 @@ var ESCRIBIENDO = false; var HOJA_ID = ""; /* id del mensaje cuya hoja está abierta ("" = ninguna) */ + var ORIGEN_HOJA = null; /* elemento que tenía el foco al abrir la hoja */ + var FOCO_EN_HOJA = false; /* la hoja movió el foco a su interior al abrirse */ /* Solo esquemas seguros en href (defensa en profundidad para JSON importados). */ function urlSegura(u) { @@ -785,7 +787,7 @@ /* Hoja inferior iOS genérica (B la crea/destruye) en #pantalla. alClic: manejador opcional de presentación para las filas. */ - function montarHoja(titulo, contenido, pie, alClic) { + function montarHoja(titulo, contenido, pie, alClic, opts) { var pantalla = byId("pantalla"); if (!pantalla) return null; WAM.cerrarSheet(); @@ -799,6 +801,28 @@ '
' + contenido + "
" + (pie ? '
' + esc(pie) + "
" : "") + ""; pantalla.appendChild(hoja); + /* Foco (diálogo modal): se guarda el origen, se enfoca la primera fila + activa (o la X) y Tab / Mayús+Tab quedan dentro del panel. Durante + «Reproducir demo» (o con opts.sinFoco) no se roba el foco. */ + ORIGEN_HOJA = document.activeElement && document.activeElement !== document.body ? document.activeElement : null; + var enDemo = typeof WAM.demoActiva === "function" && WAM.demoActiva(); + if (!enDemo && !(opts && opts.sinFoco)) { + var primera = hoja.querySelector('.sheet-fila:not([aria-disabled="true"]):not(span)') || hoja.querySelector(".sheet-cerrar"); + if (primera) { try { primera.focus({ preventScroll: true }); } catch (e) { primera.focus(); } } + FOCO_EN_HOJA = !!primera; + } + hoja.addEventListener("keydown", function (e) { + if (e.key !== "Tab") return; + var panel = hoja.querySelector(".sheet-panel"); + var focos = panel ? Array.prototype.filter.call(panel.querySelectorAll("a[href], button:not([disabled])"), function (x) { + return !x.hidden; + }) : []; + if (!focos.length) return; + var ini = focos[0], fin = focos[focos.length - 1], act = document.activeElement; + var dentro = panel.contains(act); + if (e.shiftKey && (act === ini || !dentro)) { e.preventDefault(); fin.focus(); } + else if (!e.shiftKey && (act === fin || !dentro)) { e.preventDefault(); ini.focus(); } + }); hoja.addEventListener("click", function (e) { var t = e.target; if (!t || !t.closest) return; @@ -864,7 +888,11 @@ if (url) { enlaces += '' + icono(tipoIconoUrl(url)) + etiqueta + ""; - } else if (vacio(b.url)) { + } else if (!vacio(b.url) || b.urlBloqueada) { + /* Igual que botonAccion: URL no permitida o bloqueada → fila inerte */ + enlaces += '' + + icono("url") + etiqueta + ""; + } else { var usado = li && li.querySelector('[data-btn="' + j + '"].usado'); respuestas += '"; @@ -889,7 +917,25 @@ WAM.cerrarSheet = function () { var hoja = byId("sheet"); + var idHoja = HOJA_ID; + var origen = ORIGEN_HOJA; + var movido = FOCO_EN_HOJA; + ORIGEN_HOJA = null; + FOCO_EN_HOJA = false; + var act = document.activeElement; + /* Safari no enfoca los botones al hacer clic: el foco cae en body */ + var focoDentro = !!hoja && (hoja.contains(act) || (movido && (!act || act === document.body))); if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja); HOJA_ID = ""; + if (!focoDentro) return; + /* Devolver el foco: al origen si sigue en el DOM; si hubo re-render, al + mismo control en la fila nueva del mensaje; si no, a la conversación. */ + var destino = origen && origen.isConnected ? origen : null; + if (!destino && idHoja) { + var filaNueva = filaDe({ id: idHoja }); + if (filaNueva) destino = filaNueva.querySelector("[data-ver-todas], [data-flujo]:not([data-btn]):not([data-card])"); + } + if (!destino) destino = byId("chat-cuerpo"); + if (destino) { try { destino.focus({ preventScroll: true }); } catch (e) { destino.focus(); } } }; })(); diff --git a/js/ux.js b/js/ux.js index 2b1768a..908a44e 100644 --- a/js/ux.js +++ b/js/ux.js @@ -128,9 +128,32 @@ /* ------------------------------------------------------------------ Historial deshacer / rehacer ------------------------------------------------------------------ */ + /* Cada instantánea guarda también qué demo guardada estaba en uso + («wam-demo-activa»): al deshacer o rehacer se repone, para que + «Actualizar» nunca escriba el contenido de otra demo en su hueco. */ + function instantanea() { + return { estado: clonar(WAM.estado), activa: lsGet(CLAVE_ACTIVA) }; + } + + function reponerActiva(id) { + if (id) lsSet(CLAVE_ACTIVA, id); else lsDel(CLAVE_ACTIVA); + } + + /* Un cambio nuevo deja obsoletos los avisos con acción («Deshacer» / + «Rehacer»): su botón ya no desharía lo que anuncian. */ + function cerrarAvisosConAccion() { + var ts = document.querySelectorAll("#ed-toasts .ed-toast"); + for (var i = 0; i < ts.length; i++) { + if (!ts[i].querySelector(".ed-toast-accion")) continue; + if (typeof ts[i].__cerrar === "function") ts[i].__cerrar(); + else if (ts[i].parentNode) ts[i].parentNode.removeChild(ts[i]); + } + } + WAM.puntoDeshacer = function () { if (!WAM.estado) return; - pilaDeshacer.push(clonar(WAM.estado)); + cerrarAvisosConAccion(); + pilaDeshacer.push(instantanea()); if (pilaDeshacer.length > LIMITE_HISTORIAL) pilaDeshacer.shift(); pilaRehacer = []; pintarBotonesHistorial(); @@ -147,8 +170,10 @@ WAM.deshacer = function () { if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; } - pilaRehacer.push(clonar(WAM.estado)); - sustituirEstado(conTemaActual(pilaDeshacer.pop())); + var previo = pilaDeshacer.pop(); + pilaRehacer.push(instantanea()); + sustituirEstado(conTemaActual(previo.estado)); + reponerActiva(previo.activa); pintarBotonesHistorial(); toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } }); return true; @@ -156,8 +181,10 @@ WAM.rehacer = function () { if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; } - pilaDeshacer.push(clonar(WAM.estado)); - sustituirEstado(conTemaActual(pilaRehacer.pop())); + var siguiente = pilaRehacer.pop(); + pilaDeshacer.push(instantanea()); + sustituirEstado(conTemaActual(siguiente.estado)); + reponerActiva(siguiente.activa); pintarBotonesHistorial(); toast("Cambio rehecho"); return true; @@ -372,6 +399,18 @@ return entrada; } + /* ¿El estado actual cambió de cuenta o de número de mensajes respecto a + lo guardado en el hueco «id»? */ + function cambioSustancial(id) { + var guardada = null; + try { guardada = JSON.parse(lsGet(PREFIJO_DEMO + id) || "null"); } catch (e) { guardada = null; } + if (!guardada || typeof guardada !== "object") return false; + var nomG = (guardada.cuenta && guardada.cuenta.nombre) || ""; + var nomA = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || ""; + var nG = Array.isArray(guardada.mensajes) ? guardada.mensajes.length : 0; + return nomG !== nomA || nG !== nMensajes(); + } + function cargarDemo(id) { var bruto = lsGet(PREFIJO_DEMO + id); var datos = null; @@ -499,11 +538,21 @@ var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () { var nombre = String(inputNombre.value || "").trim() || nombreCuenta || "Mi demo"; var misma = activa && activa.nombre === nombre; + // Defensa: si lo que se va a escribir sobre una demo existente tiene + // otra cuenta u otro número de mensajes, se pide un segundo clic. + if (misma && bGuardar.getAttribute("data-confirmar") !== "1" && cambioSustancial(activa.id)) { + bGuardar.setAttribute("data-confirmar", "1"); + bGuardar.textContent = "¿Sobrescribir «" + activa.nombre + "»?"; + toast("El contenido actual no se parece al de «" + activa.nombre + "» (otra cuenta u otro número de mensajes). Pulsa otra vez para sobrescribirla o cambia el nombre para guardarla como nueva.", { ms: 7000 }); + return; + } + bGuardar.removeAttribute("data-confirmar"); var e = guardarDemoComo(nombre, misma ? activa.id : null); if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); } }); inputNombre.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); bGuardar.click(); } }); inputNombre.addEventListener("input", function () { + bGuardar.removeAttribute("data-confirmar"); bGuardar.textContent = (activa && activa.nombre === String(inputNombre.value).trim()) ? "Actualizar" : "Guardar como nueva"; }); diff --git a/tests/e2e/regresiones.spec.mjs b/tests/e2e/regresiones.spec.mjs new file mode 100644 index 0000000..d2beddc --- /dev/null +++ b/tests/e2e/regresiones.spec.mjs @@ -0,0 +1,152 @@ +// Regresiones: deshacer/rehacer con demos guardadas, avisos «Deshacer» +// obsoletos, hoja «Todas las opciones» con URLs bloqueadas y foco en las hojas. +import { test, expect, estado, filaChat, cargarEstado, rapido } from "./fixtures.mjs"; + +async function nombreCuenta(page, v) { + await page.evaluate(() => { const d = document.getElementById("bloque-cuenta"); if (d) d.open = true; }); + await page.locator("#cuenta-nombre").fill(v); + await expect.poll(async () => (await estado(page)).cuenta.nombre).toBe(v); +} + +const activaLS = (page) => page.evaluate(() => localStorage.getItem("wam-demo-activa")); + +async function guardarComo(page, nombre) { + await page.locator("#btn-demos").click(); + const dlg = page.locator("#dlg-demos"); + await dlg.locator("#dlg-demo-nombre").fill(nombre); + await dlg.getByRole("button", { name: /Guardar/ }).click(); + await expect(dlg.locator(".wam-demo-nombre", { hasText: new RegExp("^" + nombre + "$") })).toBeVisible(); + await dlg.locator(".wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click(); +} + +test.describe("Deshacer y demos guardadas", () => { + test("deshacer / rehacer reponen la demo activa y «Actualizar» no pisa otra demo", async ({ app }) => { + await guardarComo(app, "A"); + const idA = await activaLS(app); + // cargar un escenario y guardarlo como B + await app.locator("#btn-demos").click(); + await app.locator("#dlg-demos .wam-esc").first().click(); + await nombreCuenta(app, "Cuenta B"); + await guardarComo(app, "B"); + const idB = await activaLS(app); + expect(idB).not.toBe(idA); + // abrir A + await app.locator("#btn-demos").click(); + await app.locator("#dlg-demos .wam-demo").filter({ has: app.locator(".wam-demo-nombre", { hasText: /^A$/ }) }) + .getByRole("button", { name: "Abrir" }).click(); + expect(await activaLS(app)).toBe(idA); + // deshacer: vuelve el contenido de B y también la demo activa B + await app.locator("#btn-deshacer").click(); + expect((await estado(app)).cuenta.nombre).toBe("Cuenta B"); + expect(await activaLS(app)).toBe(idB); + await app.locator("#btn-demos").click(); + await expect(app.locator("#dlg-demo-nombre")).toHaveValue("B"); + await app.locator("#dlg-demos .wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click(); + // rehacer: vuelve A + await app.locator("#btn-rehacer").click(); + expect(await activaLS(app)).toBe(idA); + // deshacer un escenario (que borra la clave activa) la repone + await app.locator("#btn-demos").click(); + await app.locator("#dlg-demos .wam-esc").first().click(); + expect(await activaLS(app)).toBeNull(); + await app.locator("#btn-deshacer").click(); + expect(await activaLS(app)).toBe(idA); + // el hueco de A sigue siendo A + const slotA = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id) || "null"), idA); + expect(slotA && slotA.cuenta.nombre).not.toBe("Cuenta B"); + }); + + test("«Actualizar» pide un segundo clic si cambió la cuenta", async ({ app }) => { + await guardarComo(app, "A"); + const idA = await activaLS(app); + const nombreOriginal = (await estado(app)).cuenta.nombre; + await nombreCuenta(app, "Otra cuenta"); + await app.locator("#btn-demos").click(); + const bGuardar = app.locator("#dlg-demos .wam-dlg-primario"); + await expect(bGuardar).toHaveText("Actualizar"); + await bGuardar.click(); + await expect(bGuardar).toHaveText(/Sobrescribir/); + let slot = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id)), idA); + expect(slot.cuenta.nombre).toBe(nombreOriginal); + await bGuardar.click(); + slot = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id)), idA); + expect(slot.cuenta.nombre).toBe("Otra cuenta"); + }); + + test("el «Deshacer» de un aviso desaparece cuando hay otro cambio", async ({ app }) => { + const n = (await estado(app)).mensajes.length; + await app.evaluate(() => WAM.borrarMensaje(0)); + const accion = app.locator("#ed-toasts .ed-toast-accion"); + await expect(accion).toHaveCount(1); + await nombreCuenta(app, "Nombre nuevo"); + // sin esperar: el aviso caduca solo a los 6 s y enmascararía el fallo + expect(await accion.count()).toBe(0); + expect((await estado(app)).mensajes.length).toBe(n - 1); + }); +}); + +const PLANTILLA = [ + { + id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "Elige", + botones: [ + { texto: "Uno" }, { texto: "Dos" }, + { texto: "Bloqueado", url: "javascript:alert(1)" }, + { texto: "Relativa", url: "/menu" }, + { texto: "Cinco" }, + ], + }, + { + id: "m2", dir: "in", tipo: "list", hora: "10:01", body: "Menú", boton: "Ver menú", + secciones: [{ titulo: "S", filas: [{ id: "f1", titulo: "Fila 1" }, { id: "f2", titulo: "Fila 2" }] }], + }, +]; + +test.describe("Hojas inferiores", () => { + test.beforeEach(async ({ app }) => { + await rapido(app); + await cargarEstado(app, { mensajes: PLANTILLA }); + await expect(filaChat(app, "m1")).toHaveCount(1); + }); + + test("«Todas las opciones» pinta inertes los botones con URL bloqueada o no válida", async ({ app }) => { + const fila = filaChat(app, "m1"); + const inertesBurbuja = await fila.locator(".qr-btn-inactiva").allTextContents(); + expect(inertesBurbuja.map((t) => t.trim())).toEqual(["Bloqueado", "Relativa"]); + await fila.locator("[data-ver-todas]").click(); + const hoja = app.locator("#sheet"); + const inertesHoja = await hoja.locator('.sheet-fila.qr-btn-inactiva[aria-disabled="true"]').allTextContents(); + expect(inertesHoja.map((t) => t.trim())).toEqual(inertesBurbuja.map((t) => t.trim())); + await expect(hoja.locator("[data-opcion]")).toHaveCount(3); + await expect(hoja.locator('[data-opcion="2"], [data-opcion="3"]')).toHaveCount(0); + await expect(hoja.locator(".sheet-fila")).toHaveCount(5); + }); + + test("foco: la hoja lo recibe, lo retiene con Tab y lo devuelve al cerrar", async ({ app }) => { + const toggle = filaChat(app, "m1").locator("[data-ver-todas]"); + await toggle.focus(); + await app.keyboard.press("Enter"); + await expect(app.locator("#sheet")).toHaveCount(1); + const enHoja = () => app.evaluate(() => !!document.activeElement.closest("#sheet")); + expect(await enHoja()).toBe(true); + for (let k = 0; k < 6; k++) { + await app.keyboard.press("Tab"); + expect(await enHoja()).toBe(true); + } + await app.keyboard.press("Shift+Tab"); + expect(await enHoja()).toBe(true); + await app.keyboard.press("Escape"); + await expect(app.locator("#sheet")).toHaveCount(0); + expect(await app.evaluate(() => document.activeElement.hasAttribute("data-ver-todas"))).toBe(true); + }); + + test("foco: la lista «Ver opciones» enfoca su primera fila", async ({ app }) => { + const abridor = filaChat(app, "m2").locator("[data-flujo]:not([data-btn]):not([data-card])").first(); + await abridor.focus(); + await app.keyboard.press("Enter"); + await expect(app.locator("#sheet")).toHaveCount(1); + expect(await app.evaluate(() => document.activeElement.getAttribute("data-fila"))).toBe("0"); + await app.locator("#sheet .sheet-cerrar").click(); + await expect(app.locator("#sheet")).toHaveCount(0); + expect(await app.evaluate(() => document.activeElement.closest('[data-id="m2"]') !== null)).toBe(true); + }); +});