Bouton d’envoi de fichier HTML personnalisé avec glisser-déposer
Stylisez le bouton du champ de fichier natif avec ::file-selector-button pour conserver son comportement au clavier
et l’affichage du fichier sélectionné. Nous allons créer un seul formulaire multipart qui fonctionne sans
JavaScript, puis ajouter une zone de dépôt qui place un fichier dans ce même champ. Un récepteur local
indiquera ce qui est arrivé.
Créer les fichiers de démonstration
Utilisez une version corrective actuelle de Node.js, au minimum la 24.15.0, d’une
branche de versions maintenue, un navigateur et un shell POSIX
comme Bash sous Linux, macOS ou WSL. Cet exemple utilise les API intégrées de Node ; il ne nécessite
aucune installation de paquet ni compilation. Le récepteur utilise .mts pour que Node l’exécute comme module ES
quel que soit le package.json d’un projet parent ; consultez
les règles de modules TypeScript de Node.
Ce tutoriel a été testé sous Linux avec Node.js 24.15.0, 26.5.0 et 26.8.1, ainsi qu’avec Chromium 145 et 152.
Depuis un répertoire où vous conservez vos expérimentations, collez :
(
mkdir native-upload &&
cd native-upload &&
touch index.html styles.css enhance.js server.mts
)
Les parenthèses maintiennent votre shell dans son répertoire d’origine. Si un répertoire native-upload existe
déjà, cette commande échoue avant de toucher à son contenu. En cas d’échec, corrigez l’erreur ou
choisissez un nouveau nom de répertoire avant de continuer. Enregistrez les exemples suivants dans les
fichiers situés dans native-upload. Laissez enhance.js vide jusqu’à l’étape JavaScript facultative.
Conserver le champ de fichier natif
Enregistrez cette page complète sous native-upload/index.html :
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Native file upload</title>
<link rel="stylesheet" href="styles.css" />
<script src="enhance.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload" action="/upload"
method="post" enctype="multipart/form-data">
<section id="drop-area" aria-label="File selection">
<label for="file-input">Choose a file</label>
<p id="file-help">One file, up to 1 MiB. Nothing is saved.</p>
<input id="file-input" name="file" type="file" required
aria-describedby="file-help" />
<p id="drop-hint" hidden>Or drop one file here, then choose Upload.</p>
</section>
<p id="selection-status" role="status" aria-atomic="true"></p>
<button type="submit">Upload</button>
</form>
</main>
</body>
</html>
Il y a un seul champ de fichier, à l’intérieur du formulaire qui l’envoie. Son name="file" est le champ
multipart qu’attend le récepteur ; id="file-input" relie le libellé. required empêche le navigateur
d’envoyer le formulaire lorsque le sélecteur est vide. Un fichier vide sélectionné délibérément reste
un fichier et il est accepté ici.
Laissez le champ visible. Le masquer avec display: none le retire de la navigation au clavier,
et un libellé stylisé comme un bouton n’acquiert pas le comportement clavier d’un bouton. Pour une
explication plus complète des attributs du formulaire, consultez notre
tutoriel sur les formulaires HTML d’envoi de fichiers.
Styliser le bouton et la zone de dépôt
Enregistrez ceci sous native-upload/styles.css :
:root { color-scheme: light dark; }
:root.dark { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 1rem/1.5 system-ui, sans-serif;
background: Canvas;
color: CanvasText;
}
main { max-width: 36rem; margin: 2rem auto; padding: 1.25rem; }
label { font-weight: 600; }
input[type='file'] { display: block; width: 100%; font: inherit; }
input[type='file']::file-selector-button,
button {
font: inherit;
padding: 0.6rem 0.9rem;
border: 1px solid ButtonText;
border-radius: 0.4rem;
background: ButtonFace;
color: ButtonText;
cursor: pointer;
}
input[type='file']::file-selector-button { margin-inline-end: 0.75rem; }
input:focus-visible,
button:focus-visible { outline: 3px solid Highlight; outline-offset: 4px; }
#drop-area { border: 2px dashed GrayText; padding: 1rem; }
#drop-area.dragover { border-color: Highlight; border-style: solid; }
#selection-status { overflow-wrap: anywhere; }
Le pseudo-élément ::file-selector-button
stylise le bouton à l’intérieur du champ natif. Définissez sa police explicitement, car il n’hérite pas
nécessairement de celle du champ. Le contrôle qui l’entoure affiche toujours le nom du fichier et ouvre
le sélecteur natif. Ces couleurs système suivent le jeu de couleurs clair ou sombre du navigateur, et une
classe dark sur <html> sélectionne explicitement le jeu sombre. Le contour de focus appartient
au champ lui-même, pas à son libellé. La zone bordée de tirets est une autre surface de sélection :
elle n’a donc pas besoin d’un arrêt de tabulation supplémentaire.
Servir le formulaire et recevoir ses octets
Enregistrez ceci sous native-upload/server.mts. Il sert les trois fichiers destinés au navigateur et accepte
exactement une partie multipart nommée file, jusqu’à 1 MiB. Une limite distincte de 2 MiB plafonne
le corps de requête mis en mémoire tampon, y compris les en-têtes multipart et les éventuelles données
de fin, avant l’analyse multipart. L’analyse et le hachage nécessitent de la mémoire supplémentaire ;
il s’agit d’une petite démonstration locale, pas d’une limite de la mémoire totale du serveur.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port > 65535) {
throw new Error('Use --port with an integer from 0 to 65535.')
}
const maxFileBytes = 1024 * 1024
const maxRequestBytes = 2 * 1024 * 1024
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css; charset=utf-8'],
['/enhance.js', 'enhance.js', 'text/javascript; charset=utf-8'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
let origin = ''
function reply(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
response.end(`${message}\n\nNothing was saved. Use Back to return to the form.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.headers.host !== new URL(origin).host) {
request.resume()
reply(response, 403, 'Use the printed loopback URL.')
return
}
const asset = assets.get(request.url ?? '')
if (request.method === 'GET' && asset) {
response.writeHead(200, { 'Content-Type': asset.type })
response.end(asset.body)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
request.resume()
reply(response, 404, 'Route not found.')
return
}
if (request.headers.origin !== origin) {
request.resume()
reply(response, 403, 'Submit the form served by this receiver.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Preserve the connection for readable feedback when stopping the read loop early.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxRequestBytes) {
request.resume()
reply(response, 413, 'Request exceeds 2 MiB. Choose a smaller file.')
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': request.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form.')
return
}
const file = data.get('file')
if ([...data.keys()].length !== 1 || !(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose exactly one file in the file field.')
return
}
if (file.size > maxFileBytes) {
reply(response, 413, 'File exceeds 1 MiB. Choose a smaller file.')
return
}
const receipt = {
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex'),
}
reply(response, 200, `Received one file.\n${JSON.stringify(receipt, null, 2)}`)
}
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Upload handler failed.')
if (!response.headersSent) reply(response, 500, 'Could not process the upload.')
else response.destroy()
})
})
server.requestTimeout = 30000
server.on('error', () => {
console.error('Could not start the receiver. Check that the port is available.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}/`)
})
Le serveur met en mémoire tampon une petite requête, puis utilise Request.formData() de Node pour l’analyser.
L’option destroyOnReturn: false du flux
lui permet d’arrêter la mise en tampon d’un corps trop volumineux tout en renvoyant une réponse
HTTP 413. Le reste est ignoré. Le contenu des fichiers est haché en mémoire et n’est jamais écrit sur
le disque.
Utiliser le formulaire sans JavaScript
Depuis le répertoire qui contient native-upload, démarrez le récepteur :
node native-upload/server.mts
Ouvrez l’URL exacte affichée dans le terminal. Le serveur choisit un port disponible sur 127.0.0.1 ;
ouvrir index.html directement ne connectera pas /upload au serveur. Arrêtez-le avec Ctrl+C une fois terminé.
Redémarrez-le après avoir modifié un fichier, car les ressources du navigateur sont chargées au
démarrage. Si vous avez besoin d’un port fixe, ajoutez --port 8123 à la même commande ; un port occupé
provoque un échec avec un code de sortie non nul.
JavaScript désactivé, rechargez la page et appuyez sur Tab jusqu’au champ intitulé
Choose a file. Son contour doit être visible. Appuyez sur Espace pour
ouvrir le sélecteur, choisissez un petit fichier, puis appuyez sur Tab jusqu’à Upload et
appuyez sur Entrée. Le navigateur accède à /upload, où
Received one file. apparaît avec le nom du fichier, le nombre d’octets et
le hachage SHA-256. Cette réponse confirme la réception ; voir un nom de fichier dans le sélecteur
confirme la sélection. Utilisez le bouton Retour pour revenir. Un nouvel envoi inspecte à nouveau le
fichier sans l’enregistrer.
Ajouter le glisser-déposer sans modifier l’envoi
Enregistrez cette amélioration facultative sous native-upload/enhance.js, redémarrez le récepteur, puis
rechargez la page avec JavaScript activé :
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const area = document.getElementById('drop-area')
const status = document.getElementById('selection-status')
const hint = document.getElementById('drop-hint')
const maxFileBytes = 1024 * 1024
function checkSelection(files) {
const file = files[0]
if (files.length !== 1 || !file) {
input.value = ''
status.textContent = 'Choose exactly one file.'
return false
}
if (file.size > maxFileBytes) {
input.value = ''
status.textContent = 'File exceeds 1 MiB. Choose a smaller file.'
return false
}
status.textContent = `Selected: ${file.name}. Choose Upload to send it.`
return true
}
input.addEventListener('change', () => checkSelection(input.files))
form.addEventListener('submit', (event) => {
if (!checkSelection(input.files)) event.preventDefault()
})
area.addEventListener('dragover', (event) => {
event.preventDefault()
area.classList.add('dragover')
})
area.addEventListener('dragleave', () => area.classList.remove('dragover'))
area.addEventListener('drop', (event) => {
event.preventDefault()
area.classList.remove('dragover')
if (!event.dataTransfer || !checkSelection(event.dataTransfer.files)) return
try {
input.files = event.dataTransfer.files
} catch {
input.value = ''
status.textContent = 'Could not select this drop. Use the file picker.'
}
})
hint.hidden = false
Lisez dataTransfer.files
dans le gestionnaire drop. Affecter cet objet FileList à input.files modifie la sélection du champ
natif, comme le spécifie le
standard HTML.
Donner à la propriété value du champ un chemin du système de fichiers ne permet pas de sélectionner un
fichier.
Déposer un fichier ordinaire ne fait que le sélectionner. Le statut indique le nom du fichier et affiche Choose Upload to send it.. Choisissez Upload pour envoyer le même formulaire multipart. Sélectionner un autre fichier remplace le premier. Un dépôt trop volumineux ou contenant plusieurs fichiers efface la sélection précédente et vous invite à choisir de nouveau. Si l’affectation du dépôt échoue, utilisez le sélecteur natif. Les dossiers ne font pas partie du périmètre de cet exemple.
Il n’y a ici aucun chemin fetch() ni XHR : les envois valides passent par la navigation du
navigateur, que JavaScript soit activé ou désactivé. Si vous voulez une progression, de nouvelles
tentatives et une interface qui reste sur la page, utilisez notre
tutoriel sur l’outil d’envoi JavaScript personnalisé.
Vérifier les refus avant d’adapter le formulaire
Essayez d’envoyer le formulaire avec un sélecteur vide : le navigateur doit demander un fichier. Avec
JavaScript activé, sélectionner un fichier de plus de 1 MiB efface la sélection et affiche
File exceeds 1 MiB. Choose a smaller file..
Désactivez JavaScript et envoyez de nouveau ce fichier : le récepteur doit le refuser avec HTTP 413.
Utilisez le bouton Retour et sélectionnez un fichier plus petit pour rétablir la situation. Un fichier de
zéro octet sélectionné est accepté avec bytes: 0.
Le récepteur refuse également les champs de fichier manquants, répétés ou nommés différemment avec HTTP 400, les corps multipart mal formés avec HTTP 400 et les requêtes dépassant sa limite de tampon de 2 MiB avec HTTP 413. HTTP 403 signifie que l’hôte ou l’origine de la requête ne correspondait pas à l’URL affichée. Gardez le formulaire et le récepteur sur cette URL.
Ce récepteur local accepte tous les types de fichiers et ne conserve aucun fichier envoyé. Il ne vérifie
pas qu’un fichier est une image, un document, ni qu’il peut être traité sans risque. Un
attribut accept
peut guider le sélecteur, mais ne peut pas valider le contenu. Avant de connecter ce formulaire à un
service public, utilisez le point de terminaison d’envoi authentifié de ce service ainsi que ses
vérifications de taille et de contenu côté serveur. Veillez à ce que le nom du champ de fichier et le
champ attendu par le récepteur concordent.
