Crea un widget accesible para subir archivos con componentes web
Usa un botón nativo y un campo de archivo dentro de un elemento personalizado para seleccionar un
archivo y pasar su objeto File a tu aplicación. Este widget de subida de
archivos mantiene sus estilos en Shadow DOM, comprueba un límite de tamaño de 10 MiB y muestra
información sobre la selección mediante una región de estado. La aplicación anfitriona se encarga
de la subida; este ejemplo no realiza solicitudes de red.
¿Por qué usar componentes web para los widgets de subida de archivos?
Elige un elemento personalizado cuando varias aplicaciones necesiten el mismo control de selección sin compartir un framework de interfaz. Shadow DOM mantiene separados los estilos internos, mientras que un evento DOM proporciona una interfaz pequeña a la aplicación anfitriona. Si solo necesitas un campo con estilos en un formulario, un campo de archivo nativo es más sencillo.
Configuración de la estructura del componente web
Necesitas un navegador compatible con Custom Elements y Shadow DOM, un editor de texto y un archivo local de prueba. La demo en HTML puro no necesita un gestor de paquetes, un servidor ni una etapa de compilación. Su comportamiento con el teclado y el selector de archivos se probó en Chromium 145 en Linux; antes de publicarla, prueba las combinaciones de navegador y tecnología de asistencia que usa tu público.
Crea un directorio nuevo en tu editor. Guarda el siguiente componente completo como
file-upload-widget.js dentro de él:
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);
El constructor crea el árbol de sombra e instala los listeners una sola vez. El callback de conexión solo sincroniza el estado deshabilitado, por lo que quitar y volver a conectar el mismo elemento conserva la información mostrada sin añadir listeners. El atributo observado también permite gestionar cambios posteriores; consulta la documentación de MDN sobre el ciclo de vida de los elementos personalizados.
Guarda esta página como index.html en el mismo directorio:
<!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>
Guarda el código receptor como 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;
}
Abre index.html en el navegador. Pulsa Tab para llegar a
Choose a file,
y luego Enter o la barra espaciadora para abrir el selector nativo. Elige un archivo de como máximo
10 MiB. El widget muestra
Selected file: seguido del nombre del archivo, y la aplicación anfitriona muestra
Host received: seguido del nombre y la cantidad de bytes.
window.selectedFile contiene el File real que la aplicación
anfitriona puede utilizar.
Implementación de la navegación con teclado y la gestión del foco
El botón nativo gestiona Enter y la barra espaciadora, por lo que no necesita un controlador personalizado de keydown ni un rol de botón. Al hacer clic en él, se abre el campo oculto durante la activación del usuario. Conserva el contorno visible del foco y comprueba que el foco vuelva al botón después de seleccionar un archivo o cancelar el selector. El patrón de botón de WAI describe estas expectativas de uso del teclado.
Añade el atributo booleano disabled a file-upload-widget para
deshabilitar ambos controles nativos. Quita el atributo para volver a habilitarlos;
disabled="false" sigue deshabilitando el elemento porque el atributo está presente.
Si tu aplicación deshabilita o elimina el control que tiene el foco, mueve el foco a otro control
útil que permanezca en la aplicación anfitriona. Volver a conectar un nodo no restaura por sí solo
el foco perdido.
Incorporación de atributos ARIA para admitir lectores de pantalla
El texto visible del botón proporciona su nombre accesible. aria-describedby="fileHint" lo
vincula con el límite de tamaño dentro de la misma raíz del Shadow DOM. La
región role="status", separada e inicialmente vacía,
tiene la semántica de una región dinámica con anuncios no intrusivos;
aria-atomic="true" solicita que se anuncie todo el mensaje actualizado. Actualizar el
texto no mueve el foco al estado.
Esta semántica admite tecnologías de asistencia, pero la información visible y un árbol de accesibilidad no demuestran que se produzca un anuncio de voz. En la combinación probada de Orca 46.1 y Chromium 145 en Linux, el anuncio automático de la selección tras volver del selector nativo fue intermitente. Volver a seleccionar el mismo archivo también produce un texto idéntico, que un lector de pantalla podría no anunciar de nuevo. Mantén visible la información y realiza las comprobaciones con lectores de pantalla que se indican más abajo para las combinaciones que vayas a utilizar.
Gestión de la selección y la validación de archivos
El listener de change lee el File seleccionado,
escribe su nombre como texto en lugar de HTML y emite un evento file-selected
por cada selección aceptada. event.detail.file es el archivo, no su ruta ni una copia
reconstruida. El evento se propaga y es compuesto, por lo que una aplicación anfitriona situada
fuera de otra raíz del Shadow DOM también puede recibirlo.
El límite es de 10 × 1.024 × 1.024 bytes. Se aceptan los archivos vacíos y aquellos cuyo tamaño es igual o inferior al límite. Un archivo más grande muestra File too large (max 10 MiB). Please select another file. y no emite ningún evento de selección. El archivo anterior de la aplicación anfitriona se mantiene sin cambios. El campo se vacía tras aceptar, rechazar o ignorar una selección mientras está deshabilitado, de modo que elegir el mismo archivo después de volver a habilitarlo puede producir otro evento. Cancelar el selector no produce una nueva selección.
La validación del lado del cliente proporciona información al usuario; tu servidor de subida debe
validar de forma independiente el tamaño y el contenido, y aplicar la autorización y las cuotas de
almacenamiento. Este componente no incluye arrastrar y soltar, progreso de subida ni envío de
formularios. Vaciar su campo también implica que un formulario que lo contenga no enviará
automáticamente el archivo seleccionado. Usa el File del evento en el
código de subida de tu aplicación anfitriona.
Personalización de estilos con propiedades personalizadas de CSS
El borde del componente usa var(--border-color, #ccc). Para cambiarlo, coloca esta regla en un
elemento style dentro de la cabecera de index.html;
la regla se aplica al componente sin seleccionar elementos dentro del árbol de sombra:
file-upload-widget {
--border-color: #007bff;
}
La demo usa los colores de lienzo, texto y foco del sistema con color-scheme: light dark.
Comprueba el contraste y el indicador de foco de tu propio tema al añadir estilos. Los nombres de
archivo largos se ajustan en varias líneas en lugar de hacer que el control sea más ancho que su
contenedor.
Mejora progresiva y estrategias alternativas
El HTML inicial muestra un campo nativo con etiqueta y mantiene oculto el widget mejorado. Solo
cuando el constructor termina y el script receptor está listo, demo.js
sustituye el campo por el widget. Si JavaScript está deshabilitado, alguno de los scripts no se
descarga o el componente no se inicializa, el campo básico sigue disponible. Esto cubre más casos
que una alternativa basada únicamente en noscript.
El campo básico solo selecciona un archivo: no tiene validación de tamaño, controlador de eventos para la aplicación anfitriona ni endpoint de subida. Si un formulario debe poder enviarse sin JavaScript, conecta ese campo nativo a tu formulario multipart real y a tu servidor. No lo ocultes hasta que su sustituto pueda realizar la tarea.
Pruebas de accesibilidad con lectores de pantalla
Prueba las combinaciones de navegador y lector de pantalla que admitas, como NVDA o JAWS en Windows, VoiceOver en macOS y Orca en Linux. La prueba automatizada en Linux de este ejemplo no es un estudio de usabilidad con lectores de pantalla realizado por personas y no demuestra una accesibilidad universal. Comprueba lo siguiente:
- Tab llega a Choose a file una sola vez, con un indicador de foco visible.
- Enter y la barra espaciadora abren el selector de archivos real. La selección y la cancelación devuelven el foco al botón.
- El botón tiene un nombre, un rol de botón y una descripción del límite de tamaño. Comprueba qué se anuncia realmente, incluido si la descripción requiere el comando de ayuda del lector de pantalla.
- El lector de pantalla puede acceder al nuevo nombre de archivo y al mensaje de archivo demasiado grande. Comprueba los anuncios automáticos por separado de la lectura manual de la información, incluida la selección repetida.
- Deshabilitar y volver a habilitar el control funciona. Quitarlo y volver a conectarlo conserva la información sin duplicar eventos; la aplicación anfitriona se encarga de recuperar el foco cuando elimina un control que lo tiene.
- Con JavaScript deshabilitado y con un script del componente que falla, el campo básico sigue permitiendo seleccionar un archivo.
Integra el componente con un framework
Registra file-upload-widget.js una sola vez en el navegador antes de montar tu aplicación;
luego recibe file-selected y lee event.detail.file en la aplicación
anfitriona. Mantén el renderizado del servidor y el transporte de la subida fuera del componente.
Estos son puntos de integración para una aplicación existente, en lugar de tutoriales separados
para proyectos de cada framework:
- En React 19, los elementos personalizados admiten propiedades y controladores de eventos personalizados. Conserva el guion y el uso de mayúsculas y minúsculas del evento al vincular un evento personalizado, o instala un listener DOM mediante una referencia y elimina ese mismo listener cuando se desmonte el componente contenedor.
- En Vue, marca esta etiqueta como un elemento personalizado con
compilerOptions.isCustomElementen tu configuración de compilación de Vue. Establecerapp.config.compilerOptionssolo funciona con la compilación de plantillas en el navegador, no con componentes de archivo único precompilados. Un listener de la plantilla recibe el evento DOM. - En Angular,
CUSTOM_ELEMENTS_SCHEMApermite elementos personalizados cuyos nombres contienen guiones. Añádelo aschemasdel componente independiente que lo utiliza, o aschemasdel NgModule que lo declara si la aplicación se basa en módulos. Vincula el evento DOM en la plantilla de ese componente y gestiona suFileen la aplicación.
Conecta la aplicación anfitriona a un servicio de subida
Sustituye el controlador receptor de la demo por la operación de subida de tu aplicación y proporciona información específica sobre el éxito o el fallo de la subida. Seleccionar un archivo no demuestra que un servidor lo haya aceptado. El Robot /upload/handle de Transloadit ofrece información adicional sobre la recepción de archivos del lado del servidor; no es un endpoint que puedas usar directamente en esta demo de selección local.
