Saltar al contenido
proyecto-formulario.html · devschool

Proyecto: formulario de inscripción accesible

Lección 38 de 39 · 14 min de lectura · Actualizado el

En esta lección
  1. Paso 1: planificar los datos
  2. Paso 2: la página y el <form>
  3. Paso 3: los datos personales
  4. Paso 4: elegir una opción y varias
  5. Paso 5: experiencia, emergencias y observaciones
  6. Paso 6: la foto y los consentimientos
  7. Paso 7: estilos y mensajes de error
  8. Paso 8: el código final
  9. Paso 9: comprobar que funciona
  10. Ideas para ampliarlo
  11. Errores frecuentes
  12. Resumen

En el proyecto anterior creaste la web de Club Vertical, un club de escalada ficticio de Burgos. Ahora el club quiere que la gente se apunte por internet, así que vas a construir su formulario de inscripción: largo, con datos de distintos tipos, validado por el navegador y accesible para quien navega con teclado o con lector de pantalla. Es el tipo de formulario que te pedirán en clase y en cualquier web real.

Repasarás todo lo de formularios, más campos de formulario, validar formularios y accesibilidad, y añadirás unos estilos básicos para que el usuario vea qué ha hecho mal.

Paso 1: planificar los datos

Antes de escribir etiquetas, decide qué datos necesitas y de qué tipo es cada uno. Pide solo lo imprescindible: cada campo de más es una razón para abandonar el formulario.

BloqueCampoControlObligatorio
Tus datosNombre, apellidostext + autocompleteSí
Fecha de nacimientodate con maxSí
DNI o NIEtext + patternSí
Correo y móvilemail, tel + patternSí
Código postaltext + inputmode="numeric"No
Tipo de socioAdulto, joven o familiarradioSí
ActividadesBloque, cuerda, salidas, cursoscheckboxNo
ExperienciaNivelselectSí
Años escalandonumberNo
EmergenciasNombre y teléfono de contactotext, telSí
OtrosObservacionestextareaNo
Foto para el carnéfileNo
ConsentimientosPrivacidad, fotos, boletíncheckboxSolo privacidad

Los bloques de la tabla serán los <fieldset> del formulario: agrupar los campos ayuda a todo el mundo a orientarse, y el lector de pantalla anuncia la <legend> al entrar en cada grupo.

Paso 2: la página y el <form>

Crea inscripcion.html dentro de la carpeta del proyecto anterior, con la misma cabecera y pie que las otras páginas (aquí los omitimos para centrarnos en el formulario):

<main id="contenido">
  <h1>Hazte socio de Club Vertical</h1>
  <p>Rellena el formulario y te enviaremos la confirmación por correo en 48 horas.</p>
  <p class="nota">Los campos marcados con <span aria-hidden="true">*</span> son obligatorios.</p>

  <form action="/inscripcion" method="post" enctype="multipart/form-data">
    <!-- los bloques irán aquí -->
  </form>
</main>
  • method="post": son datos personales, no deben acabar en la URL.
  • enctype="multipart/form-data": obligatorio porque habrá un campo para subir una foto.
  • El asterisco lleva aria-hidden="true". El lector de pantalla no lee “asterisco”; lee “obligatorio” gracias al atributo required de cada campo. La frase de arriba explica el símbolo a quien lo ve.

Paso 3: los datos personales

Cada campo sigue el mismo patrón: un contenedor, su <label> enlazado con for e id, el control y, si hace falta, un texto de ayuda.

<div class="campo">
  <label for="dni">DNI o NIE <span aria-hidden="true">*</span></label>
  <input id="dni" name="dni" required
         pattern="[0-9XYZxyz][0-9]{7}[A-Za-z]" maxlength="9"
         autocomplete="off" aria-describedby="dni-ayuda"
         title="8 caracteres y una letra, sin guiones">
  <p class="ayuda" id="dni-ayuda">Ocho caracteres y una letra, sin guiones: 12345678Z</p>
</div>

La clave es aria-describedby: conecta el campo con su texto de ayuda. Al llegar al campo, el lector de pantalla lee la etiqueta, que es obligatorio y después la ayuda. El title completa el mensaje de error del navegador cuando no se cumple el pattern.

Fíjate en las decisiones de cada campo de este bloque (los tienes todos en el código final):

  • Nombre y apellidos por separado, con autocomplete="given-name" y family-name, para que el navegador los rellene solo.
  • Fecha de nacimiento con type="date", autocomplete="bday" y un max que impide fechas futuras (o de menores de 14 años, si el club lo exige).
  • DNI como texto, no como número: empieza a veces por cero o por letra. El patrón comprueba el formato, no que la letra sea correcta; eso lo hará el servidor.
  • Móvil con type="tel", que muestra el teclado de teléfono pero no valida nada; por eso le añadimos pattern="[67][0-9]{8}".
  • Código postal con inputmode="numeric" y pattern="[0-9]{5}", sin required: el patrón solo se comprueba si se escribe algo.

Paso 4: elegir una opción y varias

El tipo de socio es una elección única: botones de opción con el mismo name. Basta con poner required en uno de ellos para que el grupo entero sea obligatorio.

<fieldset>
  <legend>Tipo de socio <span aria-hidden="true">*</span></legend>
  <label><input type="radio" name="cuota" value="adulto" required> Adulto · 35 €/mes</label>
  <label><input type="radio" name="cuota" value="joven"> Joven (menos de 26) · 25 €/mes</label>
  <label><input type="radio" name="cuota" value="familiar"> Familiar · 60 €/mes</label>
</fieldset>

Las actividades admiten varias respuestas, así que son casillas. Todas comparten name="actividades" y el servidor recibirá un valor por cada casilla marcada:

<fieldset>
  <legend>¿Qué te interesa?</legend>
  <label><input type="checkbox" name="actividades" value="bloque"> Escalada en bloque</label>
  <label><input type="checkbox" name="actividades" value="cuerda"> Escalada con cuerda</label>
  <label><input type="checkbox" name="actividades" value="salidas"> Salidas a roca</label>
  <label><input type="checkbox" name="actividades" value="cursos"> Cursos</label>
</fieldset>

Nota: HTML no tiene forma de decir “marca al menos una casilla”. Si lo necesitas, tendrás que comprobarlo con JavaScript (lo verás en formularios con JavaScript) y, por supuesto, en el servidor.

Aquí la etiqueta envuelve al control, así que no hace falta for ni id. Pulsar el texto marca la casilla, lo que agranda la zona en la que se puede hacer clic.

Paso 5: experiencia, emergencias y observaciones

El nivel es una lista cerrada: un <select> con una primera opción vacía. Como su value es "", required obliga a elegir otra:

<div class="campo">
  <label for="nivel">Tu nivel <span aria-hidden="true">*</span></label>
  <select id="nivel" name="nivel" required>
    <option value="">Elige una opción</option>
    <option value="nunca">Nunca he escalado</option>
    <option value="iniciacion">Iniciación (hasta 5+)</option>
    <option value="medio">Medio (6a-6c)</option>
    <option value="avanzado">Avanzado (7a o más)</option>
  </select>
</div>

Los años de experiencia sí son una cantidad, así que type="number" con min="0" y max="60" es correcto. El contacto de emergencia lleva su propio <fieldset> y, en el teléfono, autocomplete="off": no queremos que el navegador ponga el móvil del propio usuario. Las observaciones, en un <textarea> con maxlength="500" y una ayuda que lo dice, para que nadie se lleve la sorpresa de no poder escribir más.

Paso 6: la foto y los consentimientos

<div class="campo">
  <label for="foto">Foto para el carné</label>
  <input id="foto" name="foto" type="file" accept="image/jpeg, image/png" aria-describedby="foto-ayuda">
  <p class="ayuda" id="foto-ayuda">JPG o PNG, de frente y con buena luz. Puedes traerla el primer día.</p>
</div>

<fieldset>
  <legend>Consentimientos</legend>
  <label><input type="checkbox" name="privacidad" required>
    He leído y acepto la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad (se abre en otra pestaña)</a>
    <span aria-hidden="true">*</span></label>
  <label><input type="checkbox" name="fotos" value="si"> Autorizo que aparezca en fotos del club</label>
  <label><input type="checkbox" name="boletin" value="si"> Quiero recibir el boletín mensual</label>
</fieldset>

<button type="submit">Enviar inscripción</button>
  • Las casillas opcionales no van marcadas por defecto: el consentimiento tiene que ser una acción del usuario.
  • El enlace a la privacidad se abre en otra pestaña para no perder lo escrito; es uno de los pocos casos en que target="_blank" tiene sentido, y se avisa en el texto.
  • No pongas un botón de reset: nadie lo necesita y es muy fácil pulsarlo sin querer y perderlo todo.

Paso 7: estilos y mensajes de error

La validación nativa ya funciona: si envías con un campo obligatorio vacío, el navegador lo enfoca y muestra un mensaje. Con CSS puedes hacer el error visible antes de enviar, en cuanto el usuario sale de un campo mal rellenado. Crea css/inscripcion.css:

form { max-width: 36rem; }
fieldset { border: 1px solid #cbd5e1; border-radius: 8px; margin: 0 0 1.5rem; padding: 1rem 1.25rem; }
legend { font-weight: bold; padding: 0 0.25rem; }
.campo { margin-bottom: 1rem; }
.campo label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
fieldset > label { display: block; margin: 0.4rem 0; }

input:not([type="radio"], [type="checkbox"]), select, textarea {
  width: 100%; box-sizing: border-box; padding: 0.5rem;
  font: inherit; border: 1px solid #64748b; border-radius: 6px;
}

.ayuda { margin: 0.25rem 0 0; font-size: 0.9rem; color: #475569; }

/* Error: solo después de que el usuario haya tocado el campo */
:user-invalid { border: 2px solid #b91c1c; background: #fef2f2; }
.campo:has(:user-invalid) .ayuda { color: #b91c1c; }

:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }

button {
  background: #065f46; color: #fff; border: 0; border-radius: 6px;
  padding: 0.75rem 1.5rem; font: inherit; font-weight: bold; cursor: pointer;
}

Las tres reglas importantes:

  • :user-invalid marca en rojo el campo solo cuando el usuario ya ha interactuado con él. Con :invalid todo el formulario estaría en rojo nada más cargar.
  • .campo:has(:user-invalid) .ayuda pinta de rojo el texto de ayuda del campo con error. El mensaje no depende solo del color: el borde se hace más grueso y el texto explica el formato.
  • :focus-visible deja un contorno bien visible al navegar con el teclado. Nunca quites el contorno del foco sin poner otro.

Paso 8: el código final

Este es el contenido completo de <main> en inscripcion.html:

<main id="contenido">
  <h1>Hazte socio de Club Vertical</h1>
  <p>Rellena el formulario y te enviaremos la confirmación por correo en 48 horas.</p>
  <p class="nota">Los campos marcados con <span aria-hidden="true">*</span> son obligatorios.</p>

  <form action="/inscripcion" method="post" enctype="multipart/form-data">
    <fieldset>
      <legend>Tus datos</legend>
      <div class="campo">
        <label for="nombre">Nombre <span aria-hidden="true">*</span></label>
        <input id="nombre" name="nombre" autocomplete="given-name" required>
      </div>
      <div class="campo">
        <label for="apellidos">Apellidos <span aria-hidden="true">*</span></label>
        <input id="apellidos" name="apellidos" autocomplete="family-name" required>
      </div>
      <div class="campo">
        <label for="nacimiento">Fecha de nacimiento <span aria-hidden="true">*</span></label>
        <input id="nacimiento" name="nacimiento" type="date" autocomplete="bday" max="2012-12-31" required>
      </div>
      <div class="campo">
        <label for="dni">DNI o NIE <span aria-hidden="true">*</span></label>
        <input id="dni" name="dni" required pattern="[0-9XYZxyz][0-9]{7}[A-Za-z]" maxlength="9"
               autocomplete="off" aria-describedby="dni-ayuda" title="8 caracteres y una letra, sin guiones">
        <p class="ayuda" id="dni-ayuda">Ocho caracteres y una letra, sin guiones: 12345678Z</p>
      </div>
      <div class="campo">
        <label for="email">Correo electrónico <span aria-hidden="true">*</span></label>
        <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-ayuda">
        <p class="ayuda" id="email-ayuda">Te enviaremos aquí la confirmación.</p>
      </div>
      <div class="campo">
        <label for="movil">Móvil <span aria-hidden="true">*</span></label>
        <input id="movil" name="movil" type="tel" autocomplete="tel" required
               pattern="[67][0-9]{8}" aria-describedby="movil-ayuda" title="9 cifras empezando por 6 o 7">
        <p class="ayuda" id="movil-ayuda">9 cifras, sin espacios ni prefijo.</p>
      </div>
      <div class="campo">
        <label for="cp">Código postal</label>
        <input id="cp" name="cp" inputmode="numeric" autocomplete="postal-code"
               pattern="[0-9]{5}" maxlength="5" title="5 cifras">
      </div>
    </fieldset>

    <fieldset>
      <legend>Tipo de socio <span aria-hidden="true">*</span></legend>
      <label><input type="radio" name="cuota" value="adulto" required> Adulto · 35 €/mes</label>
      <label><input type="radio" name="cuota" value="joven"> Joven (menos de 26) · 25 €/mes</label>
      <label><input type="radio" name="cuota" value="familiar"> Familiar · 60 €/mes</label>
    </fieldset>

    <fieldset>
      <legend>¿Qué te interesa?</legend>
      <label><input type="checkbox" name="actividades" value="bloque"> Escalada en bloque</label>
      <label><input type="checkbox" name="actividades" value="cuerda"> Escalada con cuerda</label>
      <label><input type="checkbox" name="actividades" value="salidas"> Salidas a roca</label>
      <label><input type="checkbox" name="actividades" value="cursos"> Cursos</label>
    </fieldset>

    <fieldset>
      <legend>Tu experiencia</legend>
      <div class="campo">
        <label for="nivel">Tu nivel <span aria-hidden="true">*</span></label>
        <select id="nivel" name="nivel" required>
          <option value="">Elige una opción</option>
          <option value="nunca">Nunca he escalado</option>
          <option value="iniciacion">Iniciación (hasta 5+)</option>
          <option value="medio">Medio (6a-6c)</option>
          <option value="avanzado">Avanzado (7a o más)</option>
        </select>
      </div>
      <div class="campo">
        <label for="anos">Años escalando</label>
        <input id="anos" name="anos" type="number" min="0" max="60" step="1">
      </div>
    </fieldset>

    <fieldset>
      <legend>Contacto de emergencia</legend>
      <div class="campo">
        <label for="emerg-nombre">Nombre <span aria-hidden="true">*</span></label>
        <input id="emerg-nombre" name="emerg_nombre" autocomplete="off" required>
      </div>
      <div class="campo">
        <label for="emerg-tel">Teléfono <span aria-hidden="true">*</span></label>
        <input id="emerg-tel" name="emerg_tel" type="tel" autocomplete="off" required
               pattern="[6789][0-9]{8}" title="9 cifras">
      </div>
    </fieldset>

    <div class="campo">
      <label for="observaciones">Observaciones para los monitores</label>
      <textarea id="observaciones" name="observaciones" rows="4" maxlength="500"
                aria-describedby="obs-ayuda"></textarea>
      <p class="ayuda" id="obs-ayuda">Opcional. Máximo 500 caracteres.</p>
    </div>

    <div class="campo">
      <label for="foto">Foto para el carné</label>
      <input id="foto" name="foto" type="file" accept="image/jpeg, image/png" aria-describedby="foto-ayuda">
      <p class="ayuda" id="foto-ayuda">JPG o PNG, de frente y con buena luz. Puedes traerla el primer día.</p>
    </div>

    <fieldset>
      <legend>Consentimientos</legend>
      <label><input type="checkbox" name="privacidad" required>
        He leído y acepto la <a href="privacidad.html" target="_blank" rel="noopener">política de privacidad (se abre en otra pestaña)</a>
        <span aria-hidden="true">*</span></label>
      <label><input type="checkbox" name="fotos" value="si"> Autorizo que aparezca en fotos del club</label>
      <label><input type="checkbox" name="boletin" value="si"> Quiero recibir el boletín mensual</label>
    </fieldset>

    <button type="submit">Enviar inscripción</button>
  </form>
</main>

Y en el <head>, junto a la hoja general, enlaza la del formulario: <link rel="stylesheet" href="css/inscripcion.css">.

Paso 9: comprobar que funciona

Prueba el formulario como lo haría cualquier persona, no solo como lo has escrito:

  1. Envíalo vacío. El navegador debe enfocar el primer campo obligatorio y mostrar un mensaje.
  2. Rellénalo mal a propósito: un DNI con guion, un móvil de 8 cifras. Al salir del campo, debe ponerse rojo y la ayuda también.
  3. Recórrelo solo con el teclado: Tab para avanzar, flechas en los botones de opción, espacio en las casillas. El foco debe verse siempre.
  4. Haz clic en cada texto: debe activar su campo.
  5. Escúchalo con el lector de pantalla del sistema (NVDA en Windows, VoiceOver en Mac). Cada campo debe anunciar etiqueta, “obligatorio” y su ayuda.
  6. Pásalo por el validador del W3C para detectar id repetidos o etiquetas sin cerrar.

Recuerda que todo esto es para el usuario. El servidor tendrá que volver a validar cada dato, como viste en seguridad en HTML.

Ideas para ampliarlo

  • Con JavaScript: mostrar el número de licencia federativa solo si se marca “estoy federado”, comprobar la letra del DNI, exigir al menos una actividad y personalizar los mensajes con setCustomValidity().
  • Con CSS: dos columnas para nombre y apellidos en pantallas anchas con Grid, y una presentación más cuidada siguiendo formularios en CSS.
  • Con PHP: recibir los datos, validarlos de verdad y guardarlos en una base de datos; empieza por formularios GET y POST.
  • Un asistente por pasos: dividir el formulario en tres pantallas con una barra de progreso.

Errores frecuentes

  • Etiquetas sin for o con un id que no coincide: el texto no activa el campo y el lector de pantalla no sabe cómo se llama.
  • Radios o casillas sueltos sin <fieldset> y <legend>: el usuario oye “Adulto, botón de opción” sin saber a qué pregunta responde.
  • Usar el placeholder como etiqueta: desaparece al escribir y suele tener poco contraste.
  • type="number" para el DNI o el código postal: pierde ceros iniciales y muestra flechas sin sentido.
  • Olvidar enctype="multipart/form-data" con un campo de archivo: solo se envía el nombre de la foto.
  • Marcar por defecto los consentimientos opcionales: el usuario debe decidirlo.
  • Pintar de rojo con :invalid: el formulario aparece lleno de errores antes de empezar.
  • Indicar los errores solo con color: añade un borde más grueso y un texto que explique qué falta.

Resumen

NecesidadSolución
Agrupar campos relacionados<fieldset> + <legend>
Asociar texto y campo<label for> o etiqueta que envuelve el control
Texto de ayuda accesiblearia-describedby apuntando a su id
Campo obligatoriorequired (en un solo radio del grupo basta)
Formato concretopattern + title + ayuda visible
Teclado y autorrellenotype, inputmode y autocomplete
Error visible a tiempo:user-invalid y :has()
Subir una fototype="file", accept y enctype="multipart/form-data"
Seguridad realValidar de nuevo en el servidor

Para terminar el curso, pon a prueba todo lo aprendido con los ejercicios de HTML.

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

proyecto-formulario.html
Resultado

Pon a prueba lo que has aprendido

[HTML] ¿Por qué el formulario del proyecto usa method="post" y enctype="multipart/form-data"?

[HTML] ¿Por qué el DNI se pone como type="text" con pattern y no como type="number"?

[HTML] ¿Por qué se pinta el error con :user-invalid y no con :invalid?

[HTML] ¿Qué solución da la lección para agrupar un grupo de radios (Adulto, Joven, Familiar) accesiblemente?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.