-
-
-
-
- {desplegada && tarjeta(() => setDesplegada(false), { marginTop: 8 })}
+
+ {tarjeta(handleCerrar, { width: esMovil ? 280 : 320 })}
);
}
diff --git a/src/components/BotonFlotante.jsx b/src/components/BotonFlotante.jsx
index c556f47..8e3496b 100644
--- a/src/components/BotonFlotante.jsx
+++ b/src/components/BotonFlotante.jsx
@@ -1,21 +1,28 @@
import { useState } from "react";
+import useEsMovil from "../hooks/useEsMovil.js";
+import { ANCHO_CUERPO } from "../styles/shared.js";
const TEXTO_TOOLTIP = "Añade un nuevo local al directorio";
-// Botón de acción flotante (FAB) para proponer un local: fijo en la esquina
-// inferior derecha, visible en PC y móvil, respetando la barra de la PWA con
-// env(safe-area-inset-bottom). Su z-index queda por debajo del modal de la
-// pasarela (1000) y del toast (2000). Muestra un tooltip propio al hacer
-// hover o recibir foco (posicionado a la izquierda) y expone aria-label
-// para accesibilidad sin ratón.
+// Botón de acción flotante (FAB) para proponer un local: fijo abajo a la
+// derecha, visible en PC y móvil, alineado con el borde derecho del cuerpo
+// central (columna de ANCHO_CUERPO px) para que acompañe al contenido en
+// pantallas anchas. En PC es más grande (64px) que en móvil (54px). Respeta
+// la barra de la PWA con env(safe-area-inset-bottom). Muestra un tooltip
+// propio al hacer hover o recibir foco (posicionado a la izquierda) y expone
+// aria-label para accesibilidad sin ratón.
export default function BotonFlotante({ onClick }) {
const [tooltipVisible, setTooltipVisible] = useState(false);
+ const esMovil = useEsMovil();
+ const tamano = esMovil ? 54 : 64; // más grande en PC
return (
setTooltipVisible(false)}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
- width: 54, height: 54, borderRadius: "50%",
+ width: tamano, height: tamano, borderRadius: "50%",
background: "#8B0000", color: "white", border: "none",
- boxShadow: "0 4px 14px rgba(139,0,0,0.4)",
- fontSize: 24, cursor: "pointer",
+ boxShadow: esMovil ? "0 4px 14px rgba(139,0,0,0.4)" : "0 6px 20px rgba(139,0,0,0.45)",
+ fontSize: esMovil ? 24 : 28, cursor: "pointer",
}}
>
diff --git a/src/components/BotonProponerArriba.jsx b/src/components/BotonProponerArriba.jsx
new file mode 100644
index 0000000..da32877
--- /dev/null
+++ b/src/components/BotonProponerArriba.jsx
@@ -0,0 +1,33 @@
+import useEsMovil from "../hooks/useEsMovil.js";
+import { ANCHO_CUERPO } from "../styles/shared.js";
+
+// Botón "Proponer local" arriba a la derecha (solo PC): hace exactamente lo
+// mismo que el FAB inferior — abre la pasarela de propuesta — pero da un
+// punto de acceso visible en la zona superior, alineado con el borde derecho
+// del cuerpo central (columna de ANCHO_CUERPO px) para acompañar al contenido
+// en pantallas anchas. En móvil no se renderiza (ahí basta el FAB inferior).
+export default function BotonProponerArriba({ onClick }) {
+ const esMovil = useEsMovil();
+ if (esMovil) return null;
+
+ return (
+
+ );
+}
diff --git a/src/styles/shared.js b/src/styles/shared.js
index fe036a2..38fecc1 100644
--- a/src/styles/shared.js
+++ b/src/styles/shared.js
@@ -14,3 +14,9 @@ export const inputStyle = {
fontSize: 14,
outline: "none",
};
+
+// Ancho máximo del cuerpo central de la página (App.jsx). Los elementos fijos
+// (p. ej. el aviso PWA en PC) se alinean a su borde de contenido con
+// calc((100vw - ANCHO_CUERPO) / 2 + padding), de modo que respiren con la
+// columna aunque la ventana sea muy ancha.
+export const ANCHO_CUERPO = 720;
diff --git a/tests/component/BannerMovil.spec.jsx b/tests/component/BannerMovil.spec.jsx
index c2eff81..ca6428e 100644
--- a/tests/component/BannerMovil.spec.jsx
+++ b/tests/component/BannerMovil.spec.jsx
@@ -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();
- 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();
- 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();
+ 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();
+ 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();
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();
- 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();
- 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();
- 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();
});
});
diff --git a/tests/component/BotonFlotante.spec.jsx b/tests/component/BotonFlotante.spec.jsx
index 1761a91..48aafdd 100644
--- a/tests/component/BotonFlotante.spec.jsx
+++ b/tests/component/BotonFlotante.spec.jsx
@@ -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( {}} />);
+ 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( {}} />);
+ const boton = screen.getByRole("button");
+ expect(boton.style.width).toBe("54px");
+ expect(boton.style.fontSize).toBe("24px");
+ });
});
diff --git a/tests/component/BotonProponerArriba.spec.jsx b/tests/component/BotonProponerArriba.spec.jsx
new file mode 100644
index 0000000..a0d7bd7
--- /dev/null
+++ b/tests/component/BotonProponerArriba.spec.jsx
@@ -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();
+ 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( {}} />);
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+});