diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts index 2a13362..c3ef32f 100644 --- a/e2e/navegacion.e2e.ts +++ b/e2e/navegacion.e2e.ts @@ -130,6 +130,69 @@ test.describe('Carrusel de la portada', () => { } }); + test('cada lámina se lleva su propio fondo', async ({ page }) => { + /* + * El fondo a sangre de cada lámina va con `z-index: -1`. Si la lámina no + * crea su propio contexto de apilamiento, ese fondo se escapa hacia atrás + * y se pinta detrás del carrusel entero: al pasar de una lámina a otra + * asomaba la foto de la de al lado, y el fondo de la que entraba daba un + * salto al terminar el fundido. + * + * `opacity: 1` no crea contexto —por eso fallaba justo en la lámina + * visible—, así que lo que se comprueba es que haya algo que sí lo cree. + */ + await page.goto('/'); + + const laminas = await page.locator('.lamina').all(); + expect(laminas.length).toBeGreaterThan(1); + + for (const lamina of laminas) { + const propio = await lamina.evaluate((el) => { + const cs = getComputedStyle(el); + return ( + cs.isolation === 'isolate' || + Number(cs.opacity) < 1 || + cs.transform !== 'none' || + cs.filter !== 'none' || + (cs.position !== 'static' && cs.zIndex !== 'auto') + ); + }); + expect(propio).toBe(true); + } + }); + + test('al pasar de lámina no asoma la foto de la otra', async ({ page }) => { + await page.goto('/'); + + // A mitad del fundido, que es cuando se veían las dos superpuestas. + await page.getByRole('button', { name: 'Ir a: Aprender a escribir letras' }).click(); + await page.waitForTimeout(300); + + // El fondo de la lámina que se va tiene que estar dentro de ella, no + // suelto por detrás: se comprueba que su opacidad efectiva la acompañe. + const asoma = await page.evaluate(() => { + const laminas = [...document.querySelectorAll('.lamina')]; + return laminas.some((l) => { + const fondo = l.querySelector('.fondo'); + if (!fondo) return false; + const cs = getComputedStyle(l); + // Una lámina que se está yendo no puede pintar su fondo a plena vista. + return Number(cs.opacity) < 0.5 && getComputedStyle(fondo).opacity === '1' && !esSuyo(l); + }); + + function esSuyo(l: Element) { + const cs = getComputedStyle(l); + return ( + cs.isolation === 'isolate' || + Number(cs.opacity) < 1 || + cs.transform !== 'none' || + (cs.position !== 'static' && cs.zIndex !== 'auto') + ); + } + }); + expect(asoma).toBe(false); + }); + test('se puede parar el paso automático', async ({ page }) => { await page.goto('/'); diff --git a/e2e/tienda.e2e.ts b/e2e/tienda.e2e.ts index 56aa710..d7452db 100644 --- a/e2e/tienda.e2e.ts +++ b/e2e/tienda.e2e.ts @@ -96,12 +96,125 @@ test.describe('Carrito', () => { }); }); +test.describe('Modal de acceso', () => { + test('«Entrar» abre el diálogo sin salir de la página', async ({ page }) => { + await page.goto('/musica'); + + await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click(); + + const dialogo = page.locator('dialog.acceso'); + await expect(dialogo).toBeVisible(); + // Sigue siendo /musica: entrar no debería sacarte de donde estabas. + await expect(page).toHaveURL(/\/musica$/); + }); + + test('tiene las dos mitades: imagen a un lado y formulario al otro', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/musica'); + await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click(); + + const imagen = page.locator('dialog.acceso .ilustracion'); + const formulario = page.locator('dialog.acceso .panel'); + await expect(imagen).toBeVisible(); + await expect(formulario).toBeVisible(); + + const izquierda = (await imagen.boundingBox())!; + const derecha = (await formulario.boundingBox())!; + // La imagen entera queda a la izquierda del formulario, sin solaparse. + expect(izquierda.x + izquierda.width).toBeLessThanOrEqual(derecha.x + 1); + }); + + test('el foco entra en el campo del correo, no en el campo trampa', async ({ page }) => { + // `showModal()` enfoca lo primero que encuentra alcanzable, y lo primero + // del formulario es la trampa contra envíos automáticos: está fuera de + // pantalla pero se puede enfocar. + await page.goto('/musica'); + await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click(); + + await expect(page.locator('dialog.acceso #email')).toBeFocused(); + }); + + test('se cierra con Escape y el foco vuelve a «Entrar»', async ({ page }) => { + await page.goto('/musica'); + const abrir = page.locator('header').getByRole('link', { name: 'Entrar', exact: true }); + await abrir.click(); + await expect(page.locator('dialog.acceso')).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(page.locator('dialog.acceso')).toBeHidden(); + await expect(abrir).toBeFocused(); + }); + + test('cerrado no ocupa sitio en la página', async ({ page }) => { + /* + * El navegador oculta un `` cerrado, pero cualquier regla nuestra + * de `display` le gana por venir de la hoja del autor. Con una suelta, el + * diálogo cerrado añadía una pantalla entera de vacío al final de todas + * las páginas del sitio. + */ + await page.goto('/taller'); + + const oculto = await page + .locator('dialog.acceso') + .evaluate((d) => getComputedStyle(d).display === 'none'); + expect(oculto).toBe(true); + }); + + test('desde el modal, la vuelta apunta a donde se estaba', async ({ page }) => { + await page.goto('/musica/baladas-romanticas'); + await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click(); + + await expect(page.locator('dialog.acceso input[name="volverA"]')).toHaveValue( + '/musica/baladas-romanticas' + ); + }); + + test('los enlaces legales abren aparte y no se llevan el formulario', async ({ + page, + context + }) => { + /* + * Desde un modal, un enlace normal navega por debajo: la página cambia sin + * que se vea y, al cerrar el diálogo, has aterrizado en otro sitio con lo + * escrito perdido. + */ + await page.goto('/musica'); + await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click(); + await page.fill('dialog.acceso #email', 'sigue.aqui@example.com'); + + const [aparte] = await Promise.all([ + context.waitForEvent('page'), + page.locator('dialog.acceso').getByRole('link', { name: 'condiciones de uso' }).click() + ]); + await aparte.waitForLoadState(); + + expect(aparte.url()).toContain('/legal/condiciones-de-uso'); + await expect(page).toHaveURL(/\/musica$/); + await expect(page.locator('dialog.acceso')).toBeVisible(); + await expect(page.locator('dialog.acceso #email')).toHaveValue('sigue.aqui@example.com'); + await aparte.close(); + }); + + test('sin JavaScript, «Entrar» sigue llevando a la página', async ({ browser }) => { + const contexto = await browser.newContext({ javaScriptEnabled: false }); + const pagina = await contexto.newPage(); + await pagina.goto('/musica'); + + await pagina.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click(); + + await expect(pagina).toHaveURL(/\/entrar/); + await expect(pagina.getByLabel('Correo electrónico')).toBeVisible(); + await contexto.close(); + }); +}); + test.describe('Cuenta', () => { test('la cuenta exige entrar', async ({ page }) => { await page.goto('/cuenta'); await expect(page).toHaveURL(/\/entrar/); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Entrar'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Entrar o crear cuenta'); }); test('pedir un código deja el alta en marcha', async ({ page }) => { diff --git a/src/lib/acceso.svelte.ts b/src/lib/acceso.svelte.ts new file mode 100644 index 0000000..52a088a --- /dev/null +++ b/src/lib/acceso.svelte.ts @@ -0,0 +1,40 @@ +/** + * Quién abre el modal de acceso y desde dónde. + * + * Vive fuera del componente porque quien lo abre —la cabecera, el carrito, un + * botón de valorar— no es quien lo pinta: el diálogo se monta una sola vez en + * el layout, y así no hay uno por página ni hace falta pasarse una propiedad + * de arriba abajo. + */ + +class ControlDeAcceso { + /** Si el diálogo está pedido. Lo baja el propio componente al cerrarse. */ + abierto = $state(false); + + /** + * A dónde volver cuando la sesión se abra. + * + * Desde el modal es la página en la que se está: quien pulsa «Entrar» + * mientras mira un disco quiere seguir mirándolo, no aterrizar en su cuenta. + */ + volverA = $state('/cuenta'); + + /** El elemento que lo abrió, para devolverle el foco al cerrar. */ + #origen: HTMLElement | null = null; + + abrir(volverA?: string): void { + this.volverA = volverA ?? (typeof location === 'undefined' ? '/cuenta' : location.pathname); + this.#origen = typeof document === 'undefined' ? null : (document.activeElement as HTMLElement); + this.abierto = true; + } + + cerrar(): void { + this.abierto = false; + // Sin esto, el foco vuelve al principio del documento y quien navega con + // teclado tiene que recorrer la cabecera entera para seguir donde estaba. + this.#origen?.focus?.(); + this.#origen = null; + } +} + +export const acceso = new ControlDeAcceso(); diff --git a/src/lib/components/Cabecera.svelte b/src/lib/components/Cabecera.svelte index 3711dec..65d65b3 100644 --- a/src/lib/components/Cabecera.svelte +++ b/src/lib/components/Cabecera.svelte @@ -1,6 +1,7 @@ + +{#if estado.paso === 'email'} +

+ Escribe tu correo y te enviamos un código de seis dígitos. No hace falta contraseña: si es la + primera vez, la cuenta se crea sola. +

+ + {#if estado.error} + + {/if} + +
+ + + + + +
+ + +
+ + +
+ + +

+ Al entrar aceptas las condiciones de uso + y la + política de privacidad. +

+{:else} +

+ Hemos enviado un código a {estado.email}. Caduca en quince minutos. +

+ + {#if estado.error} + + {/if} + +
+ + + +
+ + +
+ + +
+ +
+
+ + + +
+ + +
+{/if} + + diff --git a/src/lib/components/ModalAcceso.svelte b/src/lib/components/ModalAcceso.svelte new file mode 100644 index 0000000..7db93c3 --- /dev/null +++ b/src/lib/components/ModalAcceso.svelte @@ -0,0 +1,292 @@ + + + + acceso.cerrar()} + onclick={(evento) => { + // Pulsar en el fondo cierra. El fondo es el propio : su caja + // ocupa la pantalla entera y el contenido va en un hijo, así que un + // clic cuyo destino sea el diálogo mismo es un clic fuera. + if (evento.target === dialogo) acceso.cerrar(); + }} +> +
+ + + +
+

Entrar o crear cuenta

+ + {#if acceso.abierto} + + acceso.cerrar()} /> + {/if} + + + +
+
+
+ + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b0da955..3c4a878 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import Cabecera from '$lib/components/Cabecera.svelte'; import PieDePagina from '$lib/components/PieDePagina.svelte'; import BarraReproductor from '$lib/components/player/BarraReproductor.svelte'; + import ModalAcceso from '$lib/components/ModalAcceso.svelte'; import { reproductor } from '$lib/player/player.svelte'; let { children } = $props(); @@ -37,6 +38,12 @@ --> + + +