Créer un widget de téléversement accessible en Web Components
Utilisez un bouton natif et un champ de sélection de fichier dans un élément personnalisé pour
sélectionner un seul fichier et transmettre son objet File à votre
application. Ce widget de téléversement conserve ses styles dans le Shadow DOM, vérifie une limite
de taille de 10 MiB et expose les informations de sélection dans une zone d’état. L’application hôte
se charge du téléversement ; cet exemple n’effectue aucune requête réseau.
Pourquoi utiliser des Web Components pour les widgets de téléversement ?
Choisissez un élément personnalisé lorsque plusieurs applications ont besoin du même contrôle de sélection sans partager de framework d’interface. Le Shadow DOM isole les styles internes, tandis qu’un événement DOM fournit une interface minimale à l’application hôte. Si vous avez seulement besoin d’un champ stylisé dans un formulaire, un champ natif de sélection de fichier est plus simple.
Mettre en place la structure du Web Component
Vous avez besoin d’un navigateur prenant en charge Custom Elements et Shadow DOM, d’un éditeur de texte et d’un fichier local pour les tests. La démonstration en HTML simple ne nécessite ni gestionnaire de paquets, ni serveur, ni étape de compilation. Son comportement au clavier et dans la boîte de sélection de fichier a été testé dans Chromium 145 sous Linux ; testez les combinaisons de navigateurs et de technologies d’assistance utilisées par votre public avant la mise en production.
Créez un nouveau répertoire dans votre éditeur. Enregistrez le composant complet suivant sous le nom
file-upload-widget.js dans ce répertoire :
class FileUploadWidget extends HTMLElement {
static observedAttributes = ['disabled'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
this.addEventListeners();
this.ready = true;
}
connectedCallback() {
this.syncDisabled();
}
attributeChangedCallback() {
this.syncDisabled();
}
syncDisabled() {
const disabled = this.hasAttribute('disabled');
this.shadowRoot.querySelector('button').disabled = disabled;
this.shadowRoot.querySelector('input').disabled = disabled;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 2px dashed var(--border-color, #ccc);
padding: 20px;
text-align: center;
color-scheme: light dark;
background-color: Canvas;
color: CanvasText;
}
:host([hidden]) { display: none; }
button:focus-visible {
outline: 2px solid Highlight;
outline-offset: 4px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
button {
max-width: 100%;
white-space: normal;
padding: 12px;
}
.file-info {
margin-top: 10px;
font-size: 0.9em;
overflow-wrap: anywhere;
}
</style>
<input type="file" id="fileInput" hidden />
<button type="button" aria-describedby="fileHint">Choose a file</button>
<p id="fileHint">Maximum file size: 10 MiB.</p>
<p class="file-info" id="fileInfo" role="status" aria-atomic="true"></p>
`;
}
addEventListeners() {
const fileInput = this.shadowRoot.querySelector('#fileInput');
const fileInfo = this.shadowRoot.querySelector('#fileInfo');
this.shadowRoot.querySelector('button').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (this.hasAttribute('disabled')) {
fileInput.value = '';
return;
}
const file = fileInput.files[0];
if (file) {
if (file.size > 10 * 1024 * 1024) {
fileInfo.textContent = 'File too large (max 10 MiB). Please select another file.';
fileInput.value = ''; // Clear the invalid selection
return;
}
fileInfo.textContent = `Selected file: ${file.name}`;
this.dispatchEvent(new CustomEvent('file-selected', {
detail: { file },
bubbles: true,
composed: true
}));
fileInput.value = ''; // Allow the same file to be selected again.
} else {
fileInfo.textContent = '';
}
});
}
}
customElements.define('file-upload-widget', FileUploadWidget);
Le constructeur crée l’arbre du Shadow DOM et installe les écouteurs une seule fois. Le callback de connexion synchronise uniquement l’état désactivé. Retirer puis reconnecter le même élément préserve donc les informations affichées sans ajouter d’écouteurs. L’attribut observé prend également en charge les modifications ultérieures ; consultez la documentation MDN sur le cycle de vie des éléments personnalisés.
Enregistrez cette page sous le nom index.html dans le même répertoire :
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web component file selection</title>
<script src="file-upload-widget.js" defer></script>
<script src="demo.js" defer></script>
</head>
<body>
<main>
<h1>Select one file</h1>
<p id="basic-picker">
<label for="basic-file">Choose a file</label>
<input type="file" id="basic-file" name="file" />
</p>
<file-upload-widget id="picker" hidden></file-upload-widget>
<p id="received">No file received by the host.</p>
<noscript><p>The basic input selects a file; this demo does not upload it.</p></noscript>
</main>
</body>
</html>
Enregistrez le code de réception sous le nom demo.js :
const widget = document.querySelector('#picker');
const basicPicker = document.querySelector('#basic-picker');
const received = document.querySelector('#received');
widget.addEventListener('file-selected', (event) => {
const { file } = event.detail;
window.selectedFile = file;
received.textContent = `Host received: ${file.name} (${file.size} bytes).`;
});
// Keep the basic input available if the component failed to load or initialize.
if (widget.ready === true) {
basicPicker.hidden = true;
widget.hidden = false;
}
Ouvrez index.html dans le navigateur. Appuyez sur Tab pour atteindre
Choose a file,
puis sur Entrée ou Espace pour ouvrir la boîte de sélection native. Choisissez un fichier d’au plus
10 MiB. Le widget affiche
Selected file: suivi de son nom, et l’application hôte affiche
Host received: suivi du nom et du nombre d’octets.
window.selectedFile contient l’objet File réel que l’application
hôte peut utiliser.
Implémenter la navigation au clavier et la gestion du focus
Le bouton natif gère Entrée et Espace. Il n’a donc besoin ni d’un gestionnaire keydown personnalisé, ni d’un rôle de bouton explicite. Un clic ouvre le champ masqué pendant l’activation utilisateur. Conservez le contour visible du focus et vérifiez que le focus revient au bouton après la sélection d’un fichier ou l’annulation de la boîte de sélection. Le modèle de bouton WAI décrit ces comportements attendus au clavier.
Ajoutez l’attribut booléen disabled à file-upload-widget pour
désactiver les deux contrôles natifs. Retirez l’attribut pour les réactiver ;
disabled="false" désactive toujours l’élément, car l’attribut est présent. Si votre
application désactive ou retire le contrôle qui a actuellement le focus, déplacez le focus vers un
contrôle utile encore présent dans l’application hôte. Reconnecter un nœud ne restaure pas, à lui
seul, le focus perdu.
Ajouter des attributs ARIA pour les lecteurs d’écran
Le texte visible du bouton fournit son nom accessible. aria-describedby="fileHint" associe la
description de la limite de taille située dans la même racine du Shadow DOM. La
zone role="status", distincte et initialement vide,
possède la sémantique d’une région dynamique avec des annonces polies ;
aria-atomic="true" demande l’annonce de l’intégralité du message mis à jour. La mise à
jour du texte ne déplace pas le focus vers la zone d’état.
Cette sémantique facilite l’utilisation des technologies d’assistance, mais les informations visibles et un arbre d’accessibilité ne prouvent pas qu’une annonce vocale a lieu. Avec la combinaison Orca 46.1/Chromium 145 testée sous Linux, l’annonce automatique de la sélection au retour de la boîte de sélection native était intermittente. Sélectionner à nouveau le même fichier produit également un texte identique, qu’un lecteur d’écran peut ne pas annoncer de nouveau. Gardez les informations visibles et effectuez les vérifications ci-dessous avec les lecteurs d’écran pour vos combinaisons cibles.
Gérer la sélection et la validation des fichiers
L’écouteur change lit l’objet File sélectionné,
écrit son nom sous forme de texte plutôt que de HTML et émet un événement
file-selected pour une sélection acceptée. event.detail.file est le
fichier lui-même, et non son chemin ou une copie reconstituée. L’événement se propage et est composé ;
une application hôte située à l’extérieur d’une autre racine du Shadow DOM peut donc aussi le recevoir.
La limite est de 10 × 1 024 × 1 024 octets. Les fichiers vides et ceux dont la taille est inférieure ou égale à la limite sont acceptés. Un fichier plus volumineux provoque l’affichage de File too large (max 10 MiB). Please select another file. et aucun événement de sélection n’est émis. Le fichier précédent de l’application hôte reste inchangé. Le champ est vidé après une acceptation, un rejet ou une sélection ignorée pendant que le contrôle est désactivé. Choisir le même fichier après la réactivation peut donc produire un nouvel événement. L’annulation de la boîte de sélection ne produit aucune nouvelle sélection.
La validation côté client fournit des informations à l’utilisateur ; votre serveur de téléversement
doit valider indépendamment la taille et le contenu, ainsi que faire respecter les autorisations et
les quotas de stockage. Ce composant ne propose ni glisser-déposer, ni progression du téléversement,
ni envoi de formulaire. Le fait de vider son champ signifie également qu’un formulaire qui l’englobe
n’enverra pas automatiquement le fichier sélectionné. Utilisez la valeur
File de l’événement dans le code de téléversement de votre application hôte.
Personnaliser les styles avec les propriétés personnalisées CSS
La bordure du composant utilise var(--border-color, #ccc). Pour la remplacer, placez cette
règle dans un élément style, dans l’en-tête de
index.html ; elle s’applique au composant sans sélectionner d’éléments à
l’intérieur de l’arbre du Shadow DOM :
file-upload-widget {
--border-color: #007bff;
}
La démonstration utilise les couleurs système du canevas, du texte et du focus avec
color-scheme: light dark. Vérifiez le contraste et l’indicateur de focus de votre propre thème
lorsque vous ajoutez des styles. Les noms de fichiers longs passent à la ligne au lieu d’élargir le
contrôle au-delà de son conteneur.
Amélioration progressive et stratégies de secours
Le HTML initial affiche un champ natif doté d’un libellé et garde le widget enrichi masqué.
demo.js ne remplace le champ par le widget qu’une fois le constructeur
terminé et le script de réception prêt. Si JavaScript est désactivé, si l’un des scripts ne peut pas
être téléchargé ou si le composant ne peut pas s’initialiser, le champ de base reste disponible.
Cette solution couvre davantage de cas qu’un simple mécanisme de secours
noscript.
Le champ de base permet uniquement de sélectionner un fichier : il ne possède ni validation de taille, ni gestionnaire d’événement pour l’application hôte, ni point de terminaison de téléversement. Pour un formulaire qui doit pouvoir être envoyé sans JavaScript, reliez ce champ natif à votre véritable formulaire multipart et à votre serveur. Ne le masquez pas tant que son remplaçant ne peut pas accomplir la tâche.
Tester l’accessibilité avec les lecteurs d’écran
Testez les combinaisons de navigateurs et de lecteurs d’écran que vous prenez en charge, par exemple NVDA ou JAWS sous Windows, VoiceOver sous macOS et Orca sous Linux. La sonde automatisée sous Linux utilisée ici n’est pas une étude d’utilisabilité menée avec des personnes utilisant un lecteur d’écran et ne démontre pas une accessibilité universelle. Vérifiez les points suivants :
- Tab atteint Choose a file une seule fois, avec un indicateur de focus visible.
- Entrée et Espace ouvrent la véritable boîte de sélection de fichier. La sélection et l’annulation ramènent le focus au bouton.
- Le bouton possède un nom, un rôle de bouton et une description de la limite de taille. Vérifiez ce qui est effectivement annoncé, notamment si la description nécessite la commande d’aide du lecteur d’écran.
- Un nouveau nom de fichier et le message de dépassement de taille sont accessibles au lecteur d’écran. Vérifiez les annonces automatiques séparément de la lecture manuelle des informations, y compris lors de sélections répétées.
- La désactivation et la réactivation du contrôle fonctionnent. Le retirer puis le reconnecter préserve les informations sans dupliquer les événements ; l’application hôte rétablit le focus lorsqu’elle retire un contrôle qui le possède.
- Lorsque JavaScript est désactivé ou que le script du composant est défectueux, le champ de base permet toujours de sélectionner un fichier.
Intégrer le composant à un framework
Enregistrez file-upload-widget.js une seule fois dans le navigateur avant de monter votre
application, puis recevez file-selected et lisez event.detail.file
dans l’application hôte. Gardez le rendu côté serveur et le transport des fichiers téléversés en
dehors du composant. Voici des points d’intégration pour une application existante, plutôt que des
tutoriels complets de projets propres à chaque framework :
- Dans React 19, les éléments personnalisés prennent en charge les propriétés et les gestionnaires d’événements personnalisés. Conservez le trait d’union et la casse du nom de l’événement lors de la liaison d’un événement personnalisé, ou installez un écouteur DOM via une ref et retirez ce même écouteur lorsque le composant enveloppant est démonté.
- Dans Vue, déclarez cette balise comme élément personnalisé avec
compilerOptions.isCustomElementdans votre configuration de compilation Vue. Définirapp.config.compilerOptionsfonctionne uniquement avec la compilation des templates dans le navigateur, et non avec les composants monofichiers précompilés. Un écouteur défini dans un template reçoit l’événement DOM. - Dans Angular,
CUSTOM_ELEMENTS_SCHEMAautorise les éléments personnalisés dont le nom contient un trait d’union. Ajoutez-le à l’entréeschemasdu composant autonome qui l’utilise, ou à l’entréeschemasdu NgModule qui le déclare pour une application fondée sur des modules. Liez l’événement DOM dans le template de ce composant et traitez sa valeurFiledans l’application.
Relier l’application hôte à un service de téléversement
Remplacez le gestionnaire de réception de la démonstration par l’opération de téléversement de votre application et fournissez des informations distinctes sur la réussite ou l’échec du téléversement. La sélection d’un fichier ne prouve pas qu’un serveur l’a accepté. Le Robot /upload/handle de Transloadit constitue une lecture complémentaire sur l’ingestion de fichiers côté serveur ; ce n’est pas un point de terminaison utilisable tel quel pour cette démonstration de sélection locale.
