Convert contact form into global modal

codex/redisenio-dominio-musical
dev 4 weeks ago
parent e7dd9b4599
commit b390dcf6c0

@ -32,6 +32,44 @@ async function rellenar(
}
test.describe('Formulario de contacto', () => {
test('el pie lo abre como diálogo sin abandonar la página', async ({ page }) => {
await page.goto('/interpretes/pilar-na-rua');
await page.locator('html[data-hidratado]').waitFor();
const enlace = page
.getByRole('contentinfo')
.getByRole('link', { name: 'Formulario de contacto' });
await enlace.click();
const dialogo = page.getByRole('dialog', { name: 'Contacto' });
await expect(dialogo).toBeVisible();
await expect(dialogo.getByLabel('Nombre')).toBeFocused();
await expect(page).toHaveURL('/interpretes/pilar-na-rua');
await dialogo.getByRole('button', { name: 'Cerrar contacto' }).click();
await expect(dialogo).toBeHidden();
await expect(enlace).toBeFocused();
});
test('el diálogo muestra los errores del servidor y conserva lo escrito', async ({ page }) => {
await page.goto('/');
await page.locator('html[data-hidratado]').waitFor();
await page
.getByRole('contentinfo')
.getByRole('link', { name: 'Formulario de contacto' })
.click();
const dialogo = page.getByRole('dialog', { name: 'Contacto' });
await dialogo.getByLabel('Nombre').fill('A');
await dialogo.getByLabel('Correo electrónico').fill('ana@example.com');
await dialogo.getByLabel('Motivo').selectOption('colaboracion');
await dialogo.getByLabel('Mensaje').fill('Hola');
await dialogo.getByRole('button', { name: 'Enviar mensaje' }).click();
await expect(dialogo.getByText('Indica tu nombre.')).toBeVisible();
await expect(dialogo.getByText(/Cuenta un poco más/)).toBeVisible();
await expect(dialogo.getByLabel('Correo electrónico')).toHaveValue('ana@example.com');
});
test('la validación del servidor devuelve los errores por campo', async ({ page }) => {
await abrirFormulario(page);
// "A" y "Hola" pasan la validación del navegador y fallan en el servidor,

@ -0,0 +1,253 @@
<script lang="ts">
/** El mismo formulario sirve en la página directa y en el diálogo global. */
import { applyAction, enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { untrack } from 'svelte';
import { MOTIVOS, type ErroresContacto, type ValoresContacto } from '$lib/contacto';
interface RespuestaContacto {
errores?: ErroresContacto;
valores?: ValoresContacto;
exito?: boolean;
}
interface Props {
resultadoInicial?: RespuestaContacto | null;
/** Evita identificadores repetidos si el diálogo se abre sobre /contacto. */
prefijoId?: string;
compacto?: boolean;
}
let { resultadoInicial = null, prefijoId = '', compacto = false }: Props = $props();
let enviando = $state(false);
let errorConexion = $state('');
let respuesta = $state<RespuestaContacto | null>(untrack(() => resultadoInicial));
let campos = $state(
untrack(() => ({
nombre: resultadoInicial?.valores?.nombre ?? '',
email: resultadoInicial?.valores?.email ?? '',
motivo: resultadoInicial?.valores?.motivo ?? '',
mensaje: resultadoInicial?.valores?.mensaje ?? ''
}))
);
const errores = $derived(respuesta?.errores);
const exito = $derived(Boolean(respuesta?.exito));
const id = (campo: string) => (prefijoId ? `${prefijoId}-${campo}` : campo);
</script>
<div class="contacto" class:compacto>
{#if exito}
<div class="aviso exito" role="status">
<h3>Mensaje enviado</h3>
<p>Gracias por escribir. Te responderemos al correo que has indicado.</p>
</div>
{:else}
{#if errorConexion || errores?.general}
<p class="aviso error" role="alert">{errorConexion || errores?.general}</p>
{/if}
<form
method="POST"
action={resolve('/contacto')}
class="formulario"
use:enhance={() => {
enviando = true;
errorConexion = '';
respuesta = null;
return async ({ result }) => {
try {
if (result.type === 'failure' || result.type === 'success') {
respuesta = (result.data as RespuestaContacto | undefined) ?? null;
if (result.type === 'success' && respuesta?.exito) {
campos = { nombre: '', email: '', motivo: '', mensaje: '' };
}
} else if (result.type === 'redirect') {
await applyAction(result);
} else {
errorConexion =
'No se ha podido confirmar el envío. Conservamos tu mensaje; inténtalo de nuevo.';
}
} finally {
enviando = false;
}
};
}}
>
<!-- Campo trampa para envíos automáticos. -->
<div class="trampa" aria-hidden="true">
<label for={id('companiaWeb')}>No rellenes este campo</label>
<input
id={id('companiaWeb')}
name="companiaWeb"
type="text"
tabindex="-1"
autocomplete="off"
/>
</div>
<div class="campo">
<label for={id('nombre')}>Nombre</label>
<input
id={id('nombre')}
name="nombre"
type="text"
required
autocomplete="name"
bind:value={campos.nombre}
aria-invalid={errores?.nombre ? 'true' : undefined}
aria-describedby={errores?.nombre ? id('error-nombre') : undefined}
/>
{#if errores?.nombre}
<p class="error" id={id('error-nombre')}>{errores.nombre}</p>
{/if}
</div>
<div class="campo">
<label for={id('email')}>Correo electrónico</label>
<input
id={id('email')}
name="email"
type="email"
required
autocomplete="email"
bind:value={campos.email}
aria-invalid={errores?.email ? 'true' : undefined}
aria-describedby={errores?.email ? id('error-email') : undefined}
/>
{#if errores?.email}
<p class="error" id={id('error-email')}>{errores.email}</p>
{/if}
</div>
<div class="campo">
<label for={id('motivo')}>Motivo</label>
<select
id={id('motivo')}
name="motivo"
bind:value={campos.motivo}
required
aria-invalid={errores?.motivo ? 'true' : undefined}
aria-describedby={errores?.motivo ? id('error-motivo') : undefined}
>
<option value="" disabled>Elige un motivo</option>
{#each MOTIVOS as opcion (opcion.valor)}
<option value={opcion.valor}>{opcion.etiqueta}</option>
{/each}
</select>
{#if errores?.motivo}
<p class="error" id={id('error-motivo')}>{errores.motivo}</p>
{/if}
</div>
<div class="campo">
<label for={id('mensaje')}>Mensaje</label>
<textarea
id={id('mensaje')}
name="mensaje"
bind:value={campos.mensaje}
rows={compacto ? 5 : 8}
required
aria-invalid={errores?.mensaje ? 'true' : undefined}
aria-describedby={errores?.mensaje ? id('error-mensaje') : undefined}></textarea>
{#if errores?.mensaje}
<p class="error" id={id('error-mensaje')}>{errores.mensaje}</p>
{/if}
</div>
<button class="btn btn--primary" type="submit" disabled={enviando}>
{enviando ? 'Enviando...' : 'Enviar mensaje'}
</button>
</form>
{/if}
</div>
<style>
.formulario {
display: grid;
gap: var(--sp-4);
max-width: 34rem;
margin-bottom: var(--sp-8);
}
.compacto .formulario {
gap: var(--sp-3);
max-width: none;
margin-bottom: 0;
}
.campo {
display: grid;
gap: var(--sp-1);
}
label {
font-size: var(--fs-sm);
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg);
color: inherit;
font: inherit;
}
textarea {
resize: vertical;
}
[aria-invalid='true'] {
border-color: var(--c-danger);
}
.error {
margin: 0;
color: var(--c-danger);
font-size: var(--fs-sm);
}
.aviso {
max-width: 34rem;
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
margin-bottom: var(--sp-6);
}
.compacto .aviso {
margin-bottom: 0;
}
.aviso.error {
border-color: var(--c-danger);
color: var(--c-danger);
}
.aviso.exito h3 {
margin-top: 0;
font-size: var(--fs-lg);
}
.aviso.exito p {
margin: 0;
}
.trampa {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
button[type='submit'] {
justify-self: start;
}
</style>

@ -0,0 +1,231 @@
<script lang="ts">
/** Contacto global con el mismo lenguaje visual y modal que el acceso. */
import { contactoModal } from '$lib/contacto-modal.svelte';
import { heroe, site } from '$lib/site';
import FormularioContacto from './FormularioContacto.svelte';
import Icono from './Icono.svelte';
let dialogo = $state<HTMLDialogElement | null>(null);
const imagen = heroe.fondo.oscuro;
$effect(() => {
if (!dialogo) return;
if (contactoModal.abierto && !dialogo.open) {
dialogo.showModal();
dialogo.querySelector<HTMLInputElement>('input[name="nombre"]')?.focus();
} else if (!contactoModal.abierto && dialogo.open) {
dialogo.close();
}
});
</script>
<dialog
bind:this={dialogo}
class="contacto-modal"
aria-labelledby="titulo-contacto-modal"
aria-describedby="descripcion-contacto-modal"
onclose={() => contactoModal.cerrar()}
onclick={(evento) => {
if (evento.target === dialogo) contactoModal.cerrar();
}}
>
<div class="marco">
<div class="ilustracion" aria-hidden="true">
<img
src={imagen.src}
srcset={imagen.srcset}
sizes="20rem"
width={imagen.ancho}
height={imagen.alto}
alt=""
loading="lazy"
/>
<div class="sobreimpreso">
<p class="marca">Senza Paura</p>
<p class="lema">Cuéntame qué necesitas y te responderé personalmente.</p>
</div>
</div>
<div class="panel">
<h2 id="titulo-contacto-modal">Contacto</h2>
<p id="descripcion-contacto-modal" class="descripcion">
Para colaboraciones, licencias, contratación o prensa. También puedes escribir a
<a href="mailto:{site.email}">{site.email}</a>.
</p>
{#if contactoModal.abierto}
<FormularioContacto prefijoId="modal-contacto" compacto />
{/if}
<button
type="button"
class="cerrar"
onclick={() => contactoModal.cerrar()}
aria-label="Cerrar contacto"
>
<Icono nombre="cerrar" tamano={16} />
</button>
</div>
</div>
</dialog>
<style>
.contacto-modal {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: none;
background: transparent;
overflow: auto;
place-items: center;
}
.contacto-modal[open] {
display: grid;
}
.contacto-modal::backdrop {
background: rgb(3 8 20 / 72%);
backdrop-filter: blur(4px);
}
.marco {
display: grid;
grid-template-columns: 20rem minmax(0, 30rem);
width: 100%;
max-width: 50rem;
overflow: hidden;
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 24px 64px rgb(0 0 0 / 45%);
}
.ilustracion {
position: relative;
background: var(--c-bg-sunken);
}
.ilustracion img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 12% center;
}
.sobreimpreso {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--sp-2);
padding: var(--sp-5);
background: linear-gradient(
to top,
color-mix(in oklab, #030814 88%, transparent) 0%,
color-mix(in oklab, #030814 40%, transparent) 45%,
transparent 75%
);
}
.marca {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
background: var(--oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.lema {
margin: 0;
color: rgb(255 255 255 / 78%);
font-size: var(--fs-sm);
line-height: 1.5;
}
.panel {
position: relative;
padding: var(--sp-6);
}
h2 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-xl);
}
.descripcion {
margin: 0 0 var(--sp-4);
padding-right: var(--sp-5);
color: var(--c-text-muted);
font-size: var(--fs-sm);
line-height: 1.5;
}
.cerrar {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.cerrar:hover,
.cerrar:focus-visible {
color: var(--c-text);
}
@media (max-width: 50rem) {
.marco {
grid-template-columns: minmax(0, 1fr);
max-width: 30rem;
}
.ilustracion {
height: 8rem;
}
.sobreimpreso {
padding: var(--sp-4);
}
.lema {
display: none;
}
}
@media (max-width: 30rem) {
.contacto-modal {
padding: var(--sp-2);
}
.panel {
padding: var(--sp-5) var(--sp-4) var(--sp-4);
}
}
@media (max-height: 42rem) {
.marco {
grid-template-columns: minmax(0, 1fr);
max-width: 30rem;
}
.ilustracion {
display: none;
}
}
</style>

@ -2,6 +2,7 @@
/** Pie del sitio: contacto directo, redes y aviso de autoria. */
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { pedirContacto } from '$lib/contacto-modal.svelte';
import { site } from '$lib/site';
import LogoSenzaPaura from './LogoSenzaPaura.svelte';
@ -36,7 +37,11 @@
<ul class="lista">
<li><a href="mailto:{site.email}">{site.email}</a></li>
<li><a href="mailto:{site.emailBooking}">{site.emailBooking}</a></li>
<li><a href={resolve('/contacto')}>Formulario de contacto</a></li>
<li>
<a href={resolve('/contacto')} onclick={(evento) => pedirContacto(evento)}>
Formulario de contacto
</a>
</li>
</ul>
</div>

@ -0,0 +1,43 @@
/**
* Estado del diálogo global de contacto.
*
* Igual que el acceso, no pertenece al pie que lo abre: el diálogo se monta
* una sola vez en el layout para seguir disponible desde cualquier página.
*/
class ControlDeContacto {
abierto = $state(false);
#origen: HTMLElement | null = null;
abrir(): void {
this.#origen = typeof document === 'undefined' ? null : (document.activeElement as HTMLElement);
this.abierto = true;
}
cerrar(): void {
this.abierto = false;
this.#origen?.focus?.();
this.#origen = null;
}
}
export const contactoModal = new ControlDeContacto();
/**
* Abre el diálogo en una pulsación normal. El enlace conserva su `href` para
* funcionar sin JavaScript y para poder abrirse en otra pestaña.
*/
export function pedirContacto(evento: MouseEvent): boolean {
if (
evento.defaultPrevented ||
evento.metaKey ||
evento.ctrlKey ||
evento.shiftKey ||
evento.altKey ||
evento.button !== 0
)
return false;
evento.preventDefault();
contactoModal.abrir();
return true;
}

@ -1,31 +1,14 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import FormularioContacto from '$lib/components/FormularioContacto.svelte';
import Meta from '$lib/components/Meta.svelte';
import { MOTIVOS } from '$lib/contacto';
import { site } from '$lib/site';
let { form } = $props();
let enviando = $state(false);
let errorConexion = $state('');
const errores = $derived(form && 'errores' in form ? form.errores : undefined);
const valores = $derived(form && 'valores' in form ? form.valores : undefined);
const exito = $derived(Boolean(form && 'exito' in form && form.exito));
let campos = $state(
untrack(() => ({
nombre: valores?.nombre ?? '',
email: valores?.email ?? '',
motivo: valores?.motivo ?? '',
mensaje: valores?.mensaje ?? ''
}))
);
</script>
<Meta
titulo="Contacto"
descripcion="Colaboraciones, licencias, contratación de directos y prensa."
descripcion="Colaboraciones, licencias de obra, contratación de directos y prensa."
/>
<div class="container">
@ -37,121 +20,7 @@
</p>
</header>
{#if exito}
<div class="aviso exito" role="status">
<h2>Mensaje enviado</h2>
<p>Gracias por escribir. Te respondemos al correo que has indicado.</p>
</div>
{:else}
{#if errorConexion || errores?.general}
<p class="aviso error" role="alert">{errorConexion || errores?.general}</p>
{/if}
<form
method="POST"
class="formulario"
use:enhance={() => {
enviando = true;
errorConexion = '';
return async ({ result, update }) => {
try {
if (result.type === 'error') {
errorConexion =
'No se ha podido confirmar el envío. Conservamos tu mensaje; inténtalo de nuevo.';
} else {
await update({ reset: result.type === 'success' });
}
} finally {
enviando = false;
}
};
}}
>
<!--
Campo trampa: invisible y fuera del recorrido de tabulacion, pero
presente en el HTML. Los envios automaticos suelen rellenarlo.
-->
<div class="trampa" aria-hidden="true">
<label for="companiaWeb">No rellenes este campo</label>
<input id="companiaWeb" name="companiaWeb" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="campo">
<label for="nombre">Nombre</label>
<input
id="nombre"
name="nombre"
type="text"
required
autocomplete="name"
bind:value={campos.nombre}
aria-invalid={errores?.nombre ? 'true' : undefined}
aria-describedby={errores?.nombre ? 'error-nombre' : undefined}
/>
{#if errores?.nombre}
<p class="error" id="error-nombre">{errores.nombre}</p>
{/if}
</div>
<div class="campo">
<label for="email">Correo electrónico</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
bind:value={campos.email}
aria-invalid={errores?.email ? 'true' : undefined}
aria-describedby={errores?.email ? 'error-email' : undefined}
/>
{#if errores?.email}
<p class="error" id="error-email">{errores.email}</p>
{/if}
</div>
<div class="campo">
<label for="motivo">Motivo</label>
<select
id="motivo"
name="motivo"
bind:value={campos.motivo}
required
aria-invalid={errores?.motivo ? 'true' : undefined}
aria-describedby={errores?.motivo ? 'error-motivo' : undefined}
>
<option value="" disabled>Elige un motivo</option>
{#each MOTIVOS as opcion (opcion.valor)}
<option value={opcion.valor}>
{opcion.etiqueta}
</option>
{/each}
</select>
{#if errores?.motivo}
<p class="error" id="error-motivo">{errores.motivo}</p>
{/if}
</div>
<div class="campo">
<label for="mensaje">Mensaje</label>
<textarea
id="mensaje"
name="mensaje"
bind:value={campos.mensaje}
rows="8"
required
aria-invalid={errores?.mensaje ? 'true' : undefined}
aria-describedby={errores?.mensaje ? 'error-mensaje' : undefined}></textarea>
{#if errores?.mensaje}
<p class="error" id="error-mensaje">{errores.mensaje}</p>
{/if}
</div>
<button class="btn btn--primary" type="submit" disabled={enviando}>
{enviando ? 'Enviando...' : 'Enviar mensaje'}
</button>
</form>
{/if}
<FormularioContacto resultadoInicial={form} />
</div>
<style>
@ -161,84 +30,7 @@
.entradilla {
max-width: 52ch;
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.formulario {
display: grid;
gap: var(--sp-4);
max-width: 34rem;
margin-bottom: var(--sp-8);
}
.campo {
display: grid;
gap: var(--sp-1);
}
label {
font-size: var(--fs-sm);
font-weight: 600;
}
input,
select,
textarea {
font: inherit;
color: inherit;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg);
width: 100%;
}
textarea {
resize: vertical;
}
[aria-invalid='true'] {
border-color: var(--c-danger);
}
.error {
margin: 0;
font-size: var(--fs-sm);
color: var(--c-danger);
}
.aviso {
max-width: 34rem;
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
margin-bottom: var(--sp-6);
}
.aviso.error {
border-color: var(--c-danger);
color: var(--c-danger);
}
.aviso.exito h2 {
font-size: var(--fs-lg);
}
.aviso.exito p {
margin: 0;
}
/* Oculta la trampa sin usar display:none, que algunos bots detectan. */
.trampa {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
button[type='submit'] {
justify-self: start;
}
</style>

@ -5,6 +5,7 @@
import PieDePagina from '$lib/components/PieDePagina.svelte';
import BarraReproductor from '$lib/components/player/BarraReproductor.svelte';
import ModalAcceso from '$lib/components/ModalAcceso.svelte';
import ModalContacto from '$lib/components/ModalContacto.svelte';
import { reproductor } from '$lib/player/player.svelte';
import { page } from '$app/state';
import { RUTA_ESPERA } from '$lib/rutas';
@ -84,6 +85,7 @@
a través de `acceso`, sin pasarse propiedades de página en página.
-->
<ModalAcceso />
<ModalContacto />
{/if}
<style>

Loading…
Cancel
Save

Powered by TurnKey Linux.