fix(ui): botones alineados al cuerpo, FAB grande en PC y botón superior de propuesta
- FAB y aviso PWA flanquean la columna central (720px): right/left max(16px, calc((100vw - 720px)/2 + 16px)) para acompañar al contenido en pantallas anchas sin pegarse al borde de la ventana - FAB de 64px en PC (54 en móvil); PWA abajo a la izquierda en PC y móvil - Nuevo BotonProponerArriba (solo PC): píldora "Proponer local" arriba a la derecha, alineada al cuerpo, que abre la misma pasarela
This commit is contained in:
@@ -22,6 +22,16 @@ const matchMediaMock = (esMovil) => (_query) => ({
|
||||
removeListener: () => {},
|
||||
});
|
||||
|
||||
// En pantallas anchas la tarjeta acompaña a la columna de 720px (mismo borde
|
||||
// que el contenido); en estrechas queda a 16px del borde de la ventana.
|
||||
const PATRON_ALINEADO_CUERPO = /left:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/;
|
||||
|
||||
function contenedorFijo() {
|
||||
const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed");
|
||||
expect(fijos.length).toBe(1);
|
||||
return fijos[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
estadoHook.isInstalled = false;
|
||||
@@ -35,72 +45,52 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("BannerMovil en PC", () => {
|
||||
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(false)));
|
||||
|
||||
it("muestra un botón compacto arriba a la izquierda, sin la tarjeta", () => {
|
||||
describe("BannerMovil (PC y móvil): abajo a la izquierda, alineado al cuerpo", () => {
|
||||
it("en PC: tarjeta abajo a la izquierda alineada con el borde del cuerpo central, más ancha", () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
||||
render(<BannerMovil />);
|
||||
const boton = screen.getByRole("button", { name: /instalar app/i });
|
||||
expect(boton).toBeTruthy();
|
||||
|
||||
const contenedor = boton.closest("div").parentElement;
|
||||
expect(contenedor.style.position).toBe("fixed");
|
||||
expect(contenedor.style.top).toBe("20px");
|
||||
expect(contenedor.style.left).toBe("20px");
|
||||
expect(screen.queryByText("App para móvil")).toBeNull(); // tarjeta plegada
|
||||
});
|
||||
|
||||
it("sin diálogo nativo, el botón despliega la tarjeta con instrucciones; el ✕ de la tarjeta solo la recoge", () => {
|
||||
render(<BannerMovil />);
|
||||
const boton = screen.getByRole("button", { name: /instalar app/i });
|
||||
expect(boton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(boton);
|
||||
expect(screen.getByText("App para móvil")).toBeTruthy();
|
||||
expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
// la tarjeta se cierra sin activar la regla de 14 días
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeNull();
|
||||
const contenedor = contenedorFijo();
|
||||
expect(contenedor.style.bottom).toBe("0px");
|
||||
expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO);
|
||||
expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB
|
||||
// en PC la tarjeta es más grande
|
||||
const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement;
|
||||
expect(tarjeta.style.width).toBe("320px");
|
||||
});
|
||||
|
||||
it("con diálogo nativo, el botón instala directamente sin desplegar la tarjeta", async () => {
|
||||
it("en móvil: tarjeta abajo a la izquierda, a 16px en ventana estrecha, más compacta", () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(true));
|
||||
render(<BannerMovil />);
|
||||
const contenedor = contenedorFijo();
|
||||
// misma fórmula: con vw < 720px el navegador la evalúa a 16px
|
||||
expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO);
|
||||
expect(contenedor.style.bottom).toBe("0px");
|
||||
expect(contenedor.style.right).toBe("");
|
||||
const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement;
|
||||
expect(tarjeta.style.width).toBe("280px");
|
||||
});
|
||||
|
||||
it("el ✕ aplica la regla de 14 días y oculta el aviso", () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
});
|
||||
|
||||
it("con diálogo nativo, el botón de la tarjeta instala directamente", async () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
||||
estadoHook.canPromptInstall = true;
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /instalar app/i }));
|
||||
expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
});
|
||||
|
||||
it("el ✕ del botón aplica la regla de 14 días y oculta el aviso", () => {
|
||||
it("sin diálogo nativo muestra las instrucciones del navegador (no iOS)", () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ocultar aviso de instalación/i }));
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /instalar app/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BannerMovil en móvil", () => {
|
||||
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(true)));
|
||||
|
||||
it("muestra la tarjeta abajo a la izquierda (sin invadir el FAB de la derecha)", () => {
|
||||
render(<BannerMovil />);
|
||||
expect(screen.getByText("App para móvil")).toBeTruthy();
|
||||
const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed");
|
||||
expect(fijos.length).toBe(1);
|
||||
const contenedor = fijos[0];
|
||||
expect(contenedor.style.position).toBe("fixed");
|
||||
expect(contenedor.style.left).toBe("16px");
|
||||
expect(contenedor.style.bottom).toBe("0px");
|
||||
// (el paddingBottom: env(safe-area-inset-bottom) no se serializa en jsdom; se verifica visualmente)
|
||||
expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB
|
||||
});
|
||||
|
||||
it("el ✕ de la tarjeta aplica la regla de 14 días", () => {
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import BotonFlotante from "../../src/components/BotonFlotante.jsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
const matchMediaMock = (esMovil) => (_query) => ({
|
||||
matches: esMovil,
|
||||
media: _query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// por defecto PC (botón grande); cada prueba puede sobreescribirlo
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("BotonFlotante", () => {
|
||||
it("expone su propósito con aria-label (accesible sin hover)", () => {
|
||||
@@ -36,4 +52,26 @@ describe("BotonFlotante", () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("está abajo a la derecha, alineado con el borde derecho del cuerpo central, y en PC es más grande", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
const boton = screen.getByRole("button");
|
||||
const contenedor = boton.closest("div").parentElement; // button → relativo → fijo
|
||||
expect(contenedor.style.position).toBe("fixed");
|
||||
expect(contenedor.style.bottom).toBe("0px");
|
||||
// en pantallas anchas acompaña a la columna de 720px; mínimo 16px
|
||||
expect(contenedor.getAttribute("style")).toMatch(/right:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/);
|
||||
// en PC el FAB mide 64px (en móvil 54)
|
||||
expect(boton.style.width).toBe("64px");
|
||||
expect(boton.style.height).toBe("64px");
|
||||
expect(boton.style.fontSize).toBe("28px");
|
||||
});
|
||||
|
||||
it("en móvil mantiene el tamaño compacto (54px)", () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(true));
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
const boton = screen.getByRole("button");
|
||||
expect(boton.style.width).toBe("54px");
|
||||
expect(boton.style.fontSize).toBe("24px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import BotonProponerArriba from "../../src/components/BotonProponerArriba.jsx";
|
||||
|
||||
const matchMediaMock = (esMovil) => (_query) => ({
|
||||
matches: esMovil,
|
||||
media: _query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// por defecto PC; cada prueba que lo necesite lo sobreescribe
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("BotonProponerArriba (solo PC)", () => {
|
||||
it("en PC: píldora arriba a la derecha, alineada con el borde del cuerpo, que abre la pasarela", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<BotonProponerArriba onClick={onClick} />);
|
||||
const boton = screen.getByRole("button", { name: /añade un nuevo local al directorio/i });
|
||||
expect(boton.textContent).toContain("Proponer local");
|
||||
|
||||
expect(boton.style.position).toBe("fixed");
|
||||
expect(boton.style.top).toBe("20px");
|
||||
// alineado al borde derecho de la columna de 720px; mínimo 16px
|
||||
expect(boton.getAttribute("style")).toMatch(/right:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/);
|
||||
|
||||
fireEvent.click(boton);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("en móvil no se renderiza", () => {
|
||||
vi.stubGlobal("matchMedia", matchMediaMock(true));
|
||||
render(<BotonProponerArriba onClick={() => {}} />);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user