Crea un widget accesible de subida de archivos con web components
Los Web Components ofrecen una forma potente de crear elementos de interfaz reutilizables y encapsulados que funcionan sin problemas en distintos frameworks. En este DevTip veremos cómo crear un widget accesible de subida de archivos con Web Components, Shadow DOM y JavaScript moderno. Nos aseguraremos de que nuestro widget use controles de teclado nativos y una región de estado en vivo. Selecciona un archivo y emite un evento; la aplicación anfitriona es la responsable de subirlo y de informar el progreso de la subida.
¿Por qué usar web components para widgets de subida de archivos?
Los Web Components son un conjunto de API de la plataforma web que te permiten crear elementos HTML personalizados y reutilizables. Encapsulan funcionalidad y estilos, lo que los hace ideales para crear componentes de interfaz consistentes en distintos proyectos y frameworks. Al aprovechar el Shadow DOM, podemos aislar los estilos y la estructura de nuestro widget, evitando conflictos con otras partes de la aplicación.
Configurar la estructura del web component
Definamos nuestro elemento personalizado, FileUploadWidget. Esta clase encapsulará
toda la lógica y la presentación de nuestro widget. Configuraremos el Shadow DOM, renderizaremos la
estructura HTML inicial y agregaremos los event listeners necesarios.
class FileUploadWidget extends HTMLElement {
static observedAttributes = ['disabled'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
this.addEventListeners();
}
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); /* Added default for --border-color */
padding: 20px;
text-align: center;
background-color: #f9f9f9;
}
button:focus-visible {
outline: 2px solid blue;
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;
color: #333;
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')) return;
const file = fileInput.files[0];
if (file) {
// Example validation (can be expanded)
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}`;
// Dispatch an event for parent components
this.dispatchEvent(new CustomEvent('file-selected', {
detail: { file },
bubbles: true, // Allows event to bubble up through the DOM
composed: true // Allows event to cross shadow DOM boundaries
}));
fileInput.value = ''; // Allow the same file to be selected again.
} else {
fileInfo.textContent = '';
}
});
}
}
customElements.define('file-upload-widget', FileUploadWidget);
Incluye el script una vez y luego agrega <file-upload-widget></file-upload-widget> a tu página. El constructor
crea el Shadow DOM y sus listeners una sola vez, sin mutar los atributos del host. Volver a conectar
el mismo elemento conserva su estado y no duplica los listeners. El atributo observado
disabled actualiza los controles nativos, incluso cuando cambia después de la
conexión.
Implementar la navegación por teclado y la gestión del foco
El botón nativo recibe el foco con Tab y gestiona Enter y Espacio sin un manejador de teclado
personalizado. Su clic abre el selector de archivos durante la activación del usuario. Se mantiene
el contorno de foco visible y, al agregar disabled al elemento personalizado,
se desactivan tanto el botón como el input de archivos.
Agregar atributos ARIA para compatibilidad con lectores de pantalla
El texto visible del botón proporciona su nombre accesible. aria-describedby asocia el
límite de tamaño con ese botón. La región role="status" independiente anuncia de
forma cortés la retroalimentación de selección y validación; no está anidada dentro de un rol de
botón, lo que podría ocultar su semántica.
Gestionar la selección y la validación de archivos
El método addEventListeners incluye un event listener de
change en el <input type="file"> oculto. Cuando se selecciona un
archivo, este listener se activa. El código actualizado incluye:
- Mostrar el nombre del archivo seleccionado como texto, no como HTML.
- Un ejemplo básico de validación del tamaño del archivo (comprueba si el archivo supera los 10 MiB). Si la validación falla, se muestra un mensaje de error y se limpia el input de archivos.
- Despachar un
CustomEventllamadofile-selected. Este evento se propaga por el DOM y puede cruzar los límites del Shadow DOM (composed: true), lo que permite que los componentes padre u otro código JavaScript reaccionen a la selección del archivo. El objetofileseleccionado se pasa en la propiedaddetaildel evento.
La validación del lado del cliente es retroalimentación, no un límite de seguridad. El servidor de subida debe validar de forma independiente el tamaño y el contenido, aplicar la autorización y las cuotas de almacenamiento, y rechazar archivos inseguros. Este ejemplo ofrece de manera intencional la selección de archivos, no arrastrar y soltar.
Estilos personalizados con propiedades personalizadas de CSS
Para permitir que los usuarios personalicen la apariencia del widget, las propiedades
personalizadas de CSS son una excelente solución. En el bloque <style> de
nuestro componente usamos var(--border-color, #ccc). Esto significa que el borde usará la
variable --border-color si el usuario la define; de lo contrario, se usa
#ccc de forma predeterminada.
/* Example of how a user might set the custom property */
file-upload-widget {
--border-color: #007bff;
}
El componente usa el valor de respaldo directamente en su declaración de borde:
:host {
border: 2px dashed var(--border-color, #ccc);
}
La sintaxis var(--border-color, #ccc) directamente en la propiedad
border es la forma estándar de proporcionar un valor de respaldo.
Mejora progresiva y estrategias de respaldo
Es importante asegurarte de que tu widget se degrade correctamente si JavaScript está desactivado.
La etiqueta <noscript> proporciona un respaldo básico.
<noscript>
<p>
JavaScript is required for the enhanced file upload widget. Please enable JavaScript or use the
basic file input below:
</p>
<label for="basic-file">Choose a file</label>
<input type="file" id="basic-file" name="file" />
</noscript>
Este respaldo cubre el caso de JavaScript desactivado, no un fallo en la descarga o la inicialización del script. Si la selección de archivos también debe sobrevivir a esos fallos, renderiza un input nativo con etiqueta en el HTML inicial y mejóralo solo después de que el componente se inicialice correctamente. Un input de archivos por sí solo no sube nada; conecta el respaldo al formulario y al endpoint de subida de tu aplicación.
Probar la accesibilidad con lectores de pantalla
Prueba siempre tu widget con lectores de pantalla como NVDA (Windows), JAWS (Windows) o VoiceOver (macOS) para asegurarte de que sea totalmente accesible. Verifica que:
- Se pueda enfocar el widget con la tecla Tab.
- Se pueda activar con «Enter» o «Espacio».
- El lector de pantalla anuncie el nombre, el rol y la descripción del límite de tamaño del botón.
- La selección de archivos y cualquier mensaje de estado se anuncien de forma adecuada.
- Desactivar, volver a activar, eliminar y volver a conectar el widget no duplique las interacciones.
Ejemplos de integración con frameworks
Los Web Components están diseñados para integrarse sin problemas con los frameworks más populares:
- React: En React 19, los elementos personalizados admiten propiedades y manejadores de eventos personalizados.
El event listener basado en refs que se muestra a continuación también funciona en versiones
anteriores de React. Registra el elemento personalizado antes de renderizarlo.
import { useEffect, useRef } from 'react'; export function MyReactApp() { const widgetRef = useRef(null); useEffect(() => { const node = widgetRef.current; const handleFileSelected = (event) => console.log('File selected:', event.detail.file); node?.addEventListener('file-selected', handleFileSelected); return () => node?.removeEventListener('file-selected', handleFileSelected); }, []); return <file-upload-widget ref={widgetRef}></file-upload-widget>; } - Vue: Registra el widget antes de montar la aplicación. Indícale a Vue que trate
file-upload-widgetcomo un elemento personalizado mediantecompilerOptions.isCustomElement. Para los Single-File Components compilados, define esta opción en tu configuración de compilación de Vue. La siguiente configuración en tiempo de ejecución solo funciona cuando Vue compila las plantillas en el navegador:// In-browser template compilation only, before app.mount() app.config.compilerOptions.isCustomElement = tag => tag === 'file-upload-widget';<template><file-upload-widget @file-selected="onFileSelected"></file-upload-widget></template> <script setup> const onFileSelected = (event) => { console.log('File selected in Vue:', event.detail.file); }; </script> - Angular: Incluye
CUSTOM_ELEMENTS_SCHEMAen el arrayschemasdelNgModulecorrespondiente para permitir el uso de etiquetas personalizadas sin que Angular lance errores. Después puedes usar el elemento personalizado en tus plantillas y escuchar sus eventos.// In your Angular module (e.g., app.module.ts) import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; @NgModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class AppModule { }<!-- In your Angular component template --> <file-upload-widget (file-selected)="onFileSelected($event)"></file-upload-widget>
Conclusión
Crear un widget accesible de subida de archivos con Web Components garantiza la reutilización, la encapsulación y la accesibilidad en todos tus proyectos. Este enfoque proporciona una base sólida para crear experiencias de entrada de archivos robustas y fáciles de usar. Para un manejo de archivos más robusto, considera integrarlo con el Robot /upload/handle de Transloadit.
