Envoi de formulaire HTML7 min de lecture
Envoyer un formulaire HTML sans recharger la page
Envoyez un formulaire avec fetch et FormData pour que le visiteur reste sur la page, avec erreurs de champ, état de chargement, nouvelles tentatives et solution de repli quand les scripts échouent.
Écoutez l'événement submit du formulaire, appelez preventDefault() et envoyez new FormData(form) avec fetch et un en-tête Accept: application/json. Ikiform répond alors en JSON au lieu d'une redirection : 201 en cas de succès, ou des erreurs de champ que vous affichez à côté de chaque input. Gardez une vraie action et une vraie method sur le formulaire pour qu'il s'envoie encore de façon classique si votre script ne s'exécute jamais.
L'exemple ici est un encadré Cette page vous a-t-elle été utile ? en bas d'une page de documentation. C'est le cas typique où l'on reste sur la page : le lecteur est en pleine tâche, et l'envoyer sur une page de remerciement pour enregistrer un simple clic serait absurde. Le même pattern fonctionne pour n'importe quel formulaire.
Ce qui change quand vous prenez la main sur l'envoi
Un envoi de formulaire classique vous offre beaucoup sans effort : le navigateur affiche un indicateur de chargement, rend les doubles clics peu gênants, gère le bouton retour et affiche la page qui revient. Dès que vous appelez preventDefault(), tout cela devient votre travail. L'appel fetch lui-même tient en trois lignes ; le reste de cette page, c'est le travail que faisait le navigateur.
- Le navigateur exécute d'abord sa vérification des contraintes. L'événement submit ne se déclenche que si le formulaire est valide.
- Votre écouteur appelle preventDefault, donc l'onglet ne navigue pas.
- Il envoie les données du formulaire avec fetch et demande du JSON.
- Sur un 201, il remplace le formulaire par un message de statut et y déplace le focus.
- Sur un 422 ou une panne réseau, il affiche le message à côté du champ, y place le focus et réactive l'envoi.
| Ce que faisait le navigateur | Ce que doit faire votre script |
|---|---|
| Affichait un état de chargement dans l'onglet | En afficher un, et l'annoncer aux lecteurs d'écran |
| Rendait les doubles clics presque sans effet | Ignorer un second envoi tant qu'un premier est en cours |
| Affichait la page d'erreur | Associer error.fields à chaque input |
| Affichait une page d'erreur réseau | Intercepter les requêtes échouées et les délais dépassés |
| Déplaçait le focus vers la nouvelle page | Déplacer le focus vers le résultat ou le premier champ invalide |
Le formulaire
Trois champs : un oui ou non obligatoire, un commentaire facultatif et l'adresse de la page remplie par le script. Essayez la définition ci-dessous ; c'est celle qu'Ikiform impose sur le serveur, donc un commentaire de plus de 2 000 caractères reçoit aussi un 422 ici.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<fieldset>
<legend>Was this page helpful?</legend>
<label><input type="radio" name="helpful" value="yes" required /> Yes</label>
<label><input type="radio" name="helpful" value="no" /> No</label>
</fieldset>
<label for="comment">What was missing?</label>
<textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
<p id="comment-error" hidden></p>
<input type="hidden" name="page" />
<input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send</button>
<p id="feedback-status" role="status" tabindex="-1"></p>
</form>
<script type="module" src="/page-feedback.js"></script>Le formulaire est complet sans le script. Avec JavaScript désactivé ou bloqué, il s'envoie de façon classique, Ikiform répond 303 et le lecteur arrive sur la page _redirect. Le script ne fait qu'améliorer cela.
Le script
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;
pageInput.value = location.href;
function showFieldErrors(fields) {
for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
let first = null;
for (const [name, messages] of Object.entries(fields)) {
const control = form.elements.namedItem(name);
const hint = form.querySelector(`#${name}-error`);
if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
if (hint) {
hint.textContent = messages[0];
hint.hidden = false;
}
first ??= control instanceof RadioNodeList ? control[0] : control;
}
first?.focus();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (sending) return;
const body = new FormData(form, event.submitter);
body.set("_idempotency", idempotencyKey);
sending = true;
button.setAttribute("aria-disabled", "true");
status.textContent = "Sending...";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body,
signal: AbortSignal.timeout(15000),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren(status);
status.textContent = "Thanks, that helps.";
status.focus();
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
if (result.error.fields) {
status.textContent = "";
showFieldErrors(result.error.fields);
} else if (response.status === 429 && response.headers.has("Retry-After")) {
const wait = response.headers.get("Retry-After");
status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
} else {
status.textContent = result.error.message;
}
} catch {
status.textContent = "Couldn't reach the server. Check your connection and send again.";
} finally {
sending = false;
button.removeAttribute("aria-disabled");
}
});new FormData(form, event.submitter)lit chaque contrôle nommé, plus lenameet lavaluedu bouton cliqué s'il en a. Construisez-le avant de modifier quoi que ce soit dans le formulaire.- Le bouton reçoit
aria-disabled, pasdisabled. C'est l'indicateursendingqui bloque réellement, et le bouton reste focalisable pour que les utilisateurs de lecteurs d'écran ne perdent pas leur position. - Pas d'en-tête
Content-Type. Avec un corpsFormData, le navigateur définitmultipart/form-dataavec la bonne délimitation. Accept: application/jsonfait répondre Ikiform en JSON. Un appelfetchenvoieSec-Fetch-Mode: cors, qui choisirait JSON de toute façon, mais l'en-tête rend l'intention explicite.- Une clé d'idempotence par chargement de page, envoyée dans le champ
_idempotency. Si le lecteur envoie, que le réseau perd la réponse et qu'il renvoie, Ikiform rejoue la première réponse au lieu de stocker une seconde soumission. Un envoi refusé ne consomme jamais la clé, donc un renvoi corrigé passe. L'en-têteIdempotency-Keyfait la même chose, mais un en-tête personnalisé oblige le navigateur à envoyer d'abord une requête de preflight. JavaScript et fetch donne les règles exactes. AbortSignal.timeout(15000)transforme une requête bloquée en passage dans le catch au lieu d'un Sending... sans fin.- La ligne de statut a
role="status", donc son texte est lu sans déplacer le focus. En cas de succès, le focus s'y déplace car les champs autour ont disparu.
Quand le formulaire a une vérification humaine
Si le Spam check du formulaire est réglé sur Ikiform, un envoi fetch sans jeton Turnstile reçoit 403 challenge_required. Le script ci-dessus le gère en appelant form.submit(). Cette méthode ignore l'écouteur submit et envoie de façon classique, et pour un envoi classique Ikiform affiche sa propre courte page de vérification, puis continue vers votre page _redirect. Le lecteur ne quitte la page que lorsque la vérification est active.
Pour rester sur la page même dans ce cas, obtenez d'abord un jeton avec challenge(formId) du SDK et passez-le à submit(). La documentation sur le spam montre le déroulement.
L'alternative sans JavaScript
Avant fetch, l'astuce consistait à cibler une iframe masquée, pour que la réponse s'y charge et que la page ne bouge pas :
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
...
</form>Cela fonctionne encore, mais l'iframe contient une page sur ikiform.com, et le navigateur ne laisse pas votre script lire un cadre d'une autre origine. Vous ne pouvez pas distinguer un succès d'une erreur de validation, donc le lecteur n'a aucun retour dans un cas comme dans l'autre. Utilisez-la seulement là où un script ne peut vraiment pas s'exécuter.
use:enhance de SvelteKit et le <Form> de React Router envoient de façon classique avant l'hydratation de la page et sans rechargement ensuite. La vue d'ensemble les montre tous.Vérifiez que tout fonctionne
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Envoyez Oui sans commentaire. Le formulaire devient Merci, cela nous aide. et le panneau Réseau affiche
201. - Collez plus de 2 000 caractères dans le commentaire, retirez
maxlengthdans les outils de développement et envoyez. Le message apparaît sous le commentaire et le focus s'y déplace. - Double-cliquez sur Envoyer. Le tableau de bord affiche une seule soumission, pas deux.
- Désactivez JavaScript et envoyez. Vous arrivez sur la page
_redirect. - Réglez la limitation réseau sur Offline et envoyez. La ligne de statut indique qu'elle n'a pas pu joindre le serveur, et Envoyer fonctionne à nouveau.
Pièges
| Problème | Cause et solution |
|---|---|
| Un champ manque dans l'envoi | Il était disabled quand FormData a été construit. Les contrôles désactivés ne sont jamais envoyés. Construisez d'abord les données, ou verrouillez les inputs avec readonly |
400 invalid_body ou une soumission vide | Le code a défini Content-Type: multipart/form-data à la main, sans délimitation. Supprimez l'en-tête |
| La page se recharge quand même | L'écouteur a levé une exception avant preventDefault(), ou le clic est arrivé avant le chargement du script. Appelez preventDefault() en premier ; un clic précoce envoie de façon classique, ce qui fonctionne quand même |
response.json() lève une exception | La requête est partie vers un endroit qui a répondu en HTML, généralement une action relative. Lisez form.action dans la console ; ce doit être votre URL Ikiform |
403 challenge_required | La vérification humaine du formulaire est active. Rabattez-vous sur form.submit() comme ci-dessus, ou obtenez un jeton avec challenge() |
403 origin_not_allowed, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |
Questions
Ajoutez un écouteur submit et appelez event.preventDefault() en première ligne, puis envoyez vous-même les données avec fetch. Écoutez l'événement submit du formulaire, pas le click du bouton, pour que la touche Entrée soit aussi couverte.
Non, et vous ne devriez pas. Le navigateur définit multipart/form-data avec la chaîne de délimitation. Un en-tête que vous écrivez vous-même n'a pas de délimitation, et le serveur ne peut pas analyser le corps.
Oui, tant que vous écoutez l'événement submit, qui ne se déclenche qu'une fois la vérification des contraintes du navigateur réussie. Appeler form.submit() depuis le code ignore à la fois la vérification et l'événement ; form.requestSubmit() les exécute.
Ignorez les envois tant qu'une requête est en cours, et envoyez une clé d'idempotence par chargement de page, dans le champ _idempotency ou l'en-tête Idempotency-Key. Si une nouvelle tentative atteint quand même Ikiform, il rejoue la première réponse au lieu de stocker à nouveau la soumission.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.