Las limitaciones de un input
de tipo file
son múltiples: no es acumulativo, no evita repeticiones, no deja intercambiar el orden, no permite eliminar archivos adjuntados de forma individual y muchos otros defectos adquiridos por las exigencias actuales. Por ello mismo os dejo un ejemplo para disponer de un selector múltiple vitaminado con varios superpoderes.
Sus características son:
- Muestra una previa de cada archivo.
- Al añadir nuevos archivos no se eliminan los anteriores, guardando un histórico.
- Permite selección múltiple de archivos de golpe.
- Evita las repeticiones comprobando que cada archivo sea único.
- Posibilidad de eliminar de forma individual.
- Capacidad para cambiar el orden con
drag and drop
. - Todo los cambios modifican en caliente la selección nativa del
input
. - Compatible con cualquier framework JavaScript.
- Código sólido al usar un paradigma funcional.
DEMO
¿Quieres probarlo por ti mismo?
HTML
Necesitarás un input
de tipo file
con el atributo multiple
y un elemento, como una lista desordenada, para ir listando los archivos que se adjunten.
<section id="multi-selector-uniq">
<input id="files" type="file" multiple>
<ul id="preview"></ul>
</section>
En este ejemplo, dentro del <ul>
, crearemos por medio de JavaScript la siguiente estructura por cada archivo.
<li data-key="key" draggable="true">Nombre del archivo <button>Quitar</button></li>
Es importante el atributo draggable
, nos dará acceso a poder arrastrar y soltar cada <li>
. Además data-key
debe estar presente con un valor único ya que lo usaremos para localizar ele elemento que estamos arrastrando en cada momento. Puedes usar el propio nombre del archivo si lo deseas.
CSS
Intencionadamente no tiene ni una sola línea de CSS. Integra o añade todos los estilos que creas necesarios, el objetivo de este artículo no es estético.
JavaScript
En el ejemplo he usando el siguiente código JavaScript Vainilla. Si quieres integrarlo con tu framework solo debes buscar en los comentarios la palabra Demo y sustituirlo con la herramienta propia del renderizado. Cada framework es un mundo.
/*
* Variables
*/
let filesList = [];
const classDragOver = "drag-over";
const fileInputMulti = document.querySelector("#multi-selector-uniq #files");
// DEMO Preview
const multiSelectorUniqPreview = document.querySelector("#multi-selector-uniq #preview");
/*
* Functions
*/
/**
* Returns the index of an Array of Files from its name. If there are multiple files with the same name, the last one will be returned.
* @param {string} name - Name file.
* @param {Array<File>} list - List of files.
* @return number
*/
function getIndexOfFileList(name, list) {
return list.reduce(
(position, file, index) => (file.name === name ? index : position),
-1
);
}
/**
* Returns a File in text.
* @param {File} file
* @return {Promise<string>}
*/
async function encodeFileToText(file) {
return file.text().then((text) => {
return text;
});
}
/**
* Returns an Array from the union of 2 Arrays of Files avoiding repetitions.
* @param {Array<File>} newFiles
* @param {Array<File>} currentListFiles
* @return Promise<File[]>
*/
async function getUniqFiles(newFiles, currentListFiles) {
return new Promise((resolve) => {
Promise.all(newFiles.map((inputFile) => encodeFileToText(inputFile))).then(
(inputFilesText) => {
// Check all the files to save
Promise.all(
currentListFiles.map((savedFile) => encodeFileToText(savedFile))
).then((savedFilesText) => {
let newFileList = currentListFiles;
inputFilesText.forEach((inputFileText, index) => {
if (!savedFilesText.includes(inputFileText)) {
newFileList = newFileList.concat(newFiles[index]);
}
});
resolve(newFileList);
});
}
);
});
}
/**
* Only DEMO. Render preview.
* @param currentFileList
* @Only .EMO> param target.
* @
*/
function renderPreviews(currentFileList, target, inputFile) {
//
target.textContent = "";
currentFileList.forEach((file, index) => {
const myLi = document.createElement("li");
myLi.textContent = file.name;
myLi.setAttribute("draggable", 'true');
myLi.dataset.key = file.name;
myLi.addEventListener("drop", eventDrop);
myLi.addEventListener("dragover", eventDragOver);
const myButtonRemove = document.createElement("button");
myButtonRemove.textContent = "Quitar";
myButtonRemove.addEventListener("click", () => {
filesList = deleteArrayElementByIndex(currentFileList, index);
inputFile.files = arrayFilesToFileList(filesList);
return renderPreviews(filesList, multiSelectorUniqPreview, inputFile);
});
myLi.appendChild(myButtonRemove);
target.appendChild(myLi);
});
}
/**
* Returns a copy of the array by removing one position by index.
* @param {Array<any>} list
* @param {number} index
* @return {Array<any>} list
*/
function deleteArrayElementByIndex(list, index) {
return list.filter((item, itemIndex) => itemIndex !== index);
}
/**
* Returns a FileLists from an array containing Files.
* @param {Array<File>} filesList
* @return {FileList}
*/
function arrayFilesToFileList(filesList) {
return filesList.reduce(function (dataTransfer, file) {
dataTransfer.items.add(file);
return dataTransfer;
}, new DataTransfer()).files;
}
/**
* Returns a copy of the Array by swapping 2 indices.
* @param {number} firstIndex
* @param {number} secondIndex
* @param {Array<any>} list
*/
function arraySwapIndex(firstIndex, secondIndex, list) {
const tempList = list.slice();
const tmpFirstPos = tempList[firstIndex];
tempList[firstIndex] = tempList[secondIndex];
tempList[secondIndex] = tmpFirstPos;
return tempList;
}
/*
* Events
*/
// Input file
fileInputMulti.addEventListener("input", async () => {
// Get files list from <input>
const newFilesList = Array.from(fileInputMulti.files);
// Update list files
filesList = await getUniqFiles(newFilesList, filesList);
// Only DEMO. Redraw
renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
// Set data to input
fileInputMulti.files = arrayFilesToFileList(filesList);
});
// Drag and drop
// Drag Start - Moving element.
let myDragElement = undefined;
document.addEventListener("dragstart", (event) => {
// Saves which element is moving.
myDragElement = event.target;
});
// Drag over - Element that is below the element that is moving.
function eventDragOver(event) {
// Remove from all elements the class that will show that it is a drop zone.
event.preventDefault();
multiSelectorUniqPreview
.querySelectorAll("li")
.forEach((item) => item.classList.remove(classDragOver));
// On the element above it, the class is added to show that it is a drop zone.
event.target.classList.add(classDragOver);
}
// Drop - Element on which it is dropped.
function eventDrop(event) {
// The element that is underneath the element that is moving when it is released is captured.
const myDropElement = event.target;
// The positions of the elements in the array are swapped. The dataset key is used as an index.
filesList = arraySwapIndex(
getIndexOfFileList(myDragElement.dataset.key, filesList),
getIndexOfFileList(myDropElement.dataset.key, filesList),
filesList
);
// The content of the input file is updated.
fileInputMulti.files = arrayFilesToFileList(filesList);
// Only DEMO. Changes are redrawn.
renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
}
Extra: Versión Typescript
Si quieres trabajar con Typescript, a continuación dejo una versión para importar en el proyecto que estés desarrollando.
/*
* Variables
*/
let filesList: Array<File> = [];
const classDragOver: string = "drag-over";
const fileInputMulti: HTMLInputElement = document.querySelector("#multi-selector-uniq #files");
// DEMO Preview
const multiSelectorUniqPreview: HTMLElement = document.querySelector("#multi-selector-uniq #preview");
/*
* Functions
*/
/**
* Returns the index of an Array of Files from its name. If there are multiple files with the same name, the last one will be returned.
* @param {string} name - Name file.
* @param {Array<File>} list - List of files.
* @return number
*/
function getIndexOfFileList(name: string, list: Array<File>): number {
return list.reduce(
(position, file, index) => (file.name === name ? index : position),
-1
);
}
/**
* Returns a File in text.
* @param {File} file
* @return {Promise<string>}
*/
async function encodeFileToText(file: File): Promise<string> {
return file.text().then((text) => {
return text;
});
}
/**
* Returns an Array from the union of 2 Arrays of Files avoiding repetitions.
* @param {Array<File>} newFiles
* @param {Array<File>} currentListFiles
* @return Promise<File[]>
*/
async function getUniqFiles(newFiles: Array<File>, currentListFiles: Array<File>): Promise<File[]> {
return new Promise((resolve) => {
Promise.all(newFiles.map((inputFile) => encodeFileToText(inputFile))).then(
(inputFilesText) => {
// Check all the files to save
Promise.all(
currentListFiles.map((savedFile) => encodeFileToText(savedFile))
).then((savedFilesText) => {
let newFileList = currentListFiles;
inputFilesText.forEach((inputFileText, index) => {
if (!savedFilesText.includes(inputFileText)) {
newFileList = newFileList.concat(newFiles[index]);
}
});
resolve(newFileList);
});
}
);
});
}
/**
* Only DEMO. Render preview.
* @param currentFileList
* @Only .EMO> param target.
* @
*/
function renderPreviews(currentFileList, target, inputFile) {
//
target.textContent = "";
currentFileList.forEach((file, index) => {
const myLi = document.createElement("li");
myLi.textContent = file.name;
myLi.setAttribute("draggable", 'true');
myLi.dataset.key = file.name;
myLi.addEventListener("drop", eventDrop);
myLi.addEventListener("dragover", eventDragOver);
const myButtonRemove = document.createElement("button");
myButtonRemove.textContent = "X";
myButtonRemove.addEventListener("click", () => {
filesList = deleteArrayElementByIndex(currentFileList, index);
inputFile.files = arrayFilesToFileList(filesList);
return renderPreviews(filesList, multiSelectorUniqPreview, inputFile);
});
myLi.appendChild(myButtonRemove);
target.appendChild(myLi);
});
}
/**
* Returns a copy of the array by removing one position by index.
* @param {Array<any>} list
* @param {number} index
* @return {Array} list
*/
function deleteArrayElementByIndex(list: Array<any>, index: number) {
return list.filter((item, itemIndex) => itemIndex !== index);
}
/**
* Returns a FileLists from an array containing Files.
* @param {Array<File>} filesList
* @return {FileList}
*/
function arrayFilesToFileList(filesList): FileList {
return filesList.reduce(function (dataTransfer, file) {
dataTransfer.items.add(file);
return dataTransfer;
}, new DataTransfer()).files;
}
/**
* Returns a copy of the Array by swapping 2 indices.
* @param {number} firstIndex
* @param {number} secondIndex
* @param {Array<any>} list
*/
function arraySwapIndex(firstIndex: number, secondIndex: number, list: Array<any>): Array<any> {
const tempList = list.slice();
const tmpFirstPos = tempList[firstIndex];
tempList[firstIndex] = tempList[secondIndex];
tempList[secondIndex] = tmpFirstPos;
return tempList;
}
/*
* Events
*/
// Input file
fileInputMulti.addEventListener("input", async () => {
// Get files list from <input>
const newFilesList = Array.from(fileInputMulti.files);
// Update list files
filesList = await getUniqFiles(newFilesList, filesList);
// Only DEMO. Redraw
renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
// Set data to input
fileInputMulti.files = arrayFilesToFileList(filesList);
});
// Drag and drop
// Drag Start - Moving element.
let myDragElement = undefined;
document.addEventListener("dragstart", (event) => {
// Saves which element is moving.
myDragElement = event.target;
});
// Drag over - Element that is below the element that is moving.
function eventDragOver(event) {
// Remove from all elements the class that will show that it is a drop zone.
event.preventDefault();
multiSelectorUniqPreview
.querySelectorAll("li")
.forEach((item) => item.classList.remove(classDragOver));
// On the element above it, the class is added to show that it is a drop zone.
event.target.classList.add(classDragOver);
}
// Drop - Element on which it is dropped.
function eventDrop(event) {
// The element that is underneath the element that is moving when it is released is captured.
const myDropElement = event.target;
// The positions of the elements in the array are swapped. The dataset key is used as an index.
filesList = arraySwapIndex(
getIndexOfFileList(myDragElement.dataset.key, filesList),
getIndexOfFileList(myDropElement.dataset.key, filesList),
filesList
);
// The content of the input file is updated.
fileInputMulti.files = arrayFilesToFileList(filesList);
// Only DEMO. Changes are redrawn.
renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
}
Completo
Todo el código unificado quedaría de la siguiente forma.
<html>
<body>
<section id="multi-selector-uniq">
<input id="files" type="file" multiple>
<ul id="preview"></ul>
</section>
<script>
/*
* Variables
*/
let filesList = [];
const classDragOver = "drag-over";
const fileInputMulti = document.querySelector("#multi-selector-uniq #files");
// DEMO Preview
const multiSelectorUniqPreview = document.querySelector("#multi-selector-uniq #preview");
/*
* Functions
*/
/**
* Returns the index of an Array of Files from its name. If there are multiple files with the same name, the last one will be returned.
* @param {string} name - Name file.
* @param {Array<File>} list - List of files.
* @return number
*/
function getIndexOfFileList(name, list) {
return list.reduce(
(position, file, index) => (file.name === name ? index : position),
-1
);
}
/**
* Returns a File in text.
* @param {File} file
* @return {Promise<string>}
*/
async function encodeFileToText(file) {
return file.text().then((text) => {
return text;
});
}
/**
* Returns an Array from the union of 2 Arrays of Files avoiding repetitions.
* @param {Array<File>} newFiles
* @param {Array<File>} currentListFiles
* @return Promise<File[]>
*/
async function getUniqFiles(newFiles, currentListFiles) {
return new Promise((resolve) => {
Promise.all(newFiles.map((inputFile) => encodeFileToText(inputFile))).then(
(inputFilesText) => {
// Check all the files to save
Promise.all(
currentListFiles.map((savedFile) => encodeFileToText(savedFile))
).then((savedFilesText) => {
let newFileList = currentListFiles;
inputFilesText.forEach((inputFileText, index) => {
if (!savedFilesText.includes(inputFileText)) {
newFileList = newFileList.concat(newFiles[index]);
}
});
resolve(newFileList);
});
}
);
});
}
/**
* Only DEMO. Render preview.
* @param currentFileList
* @Only .EMO> param target.
* @
*/
function renderPreviews(currentFileList, target, inputFile) {
//
target.textContent = "";
currentFileList.forEach((file, index) => {
const myLi = document.createElement("li");
myLi.textContent = file.name;
myLi.setAttribute("draggable", 'true');
myLi.dataset.key = file.name;
myLi.addEventListener("drop", eventDrop);
myLi.addEventListener("dragover", eventDragOver);
const myButtonRemove = document.createElement("button");
myButtonRemove.textContent = "X";
myButtonRemove.addEventListener("click", () => {
filesList = deleteArrayElementByIndex(currentFileList, index);
inputFile.files = arrayFilesToFileList(filesList);
return renderPreviews(filesList, multiSelectorUniqPreview, inputFile);
});
myLi.appendChild(myButtonRemove);
target.appendChild(myLi);
});
}
/**
* Returns a copy of the array by removing one position by index.
* @param {Array<any>} list
* @param {number} index
* @return {Array<any>} list
*/
function deleteArrayElementByIndex(list, index) {
return list.filter((item, itemIndex) => itemIndex !== index);
}
/**
* Returns a FileLists from an array containing Files.
* @param {Array<File>} filesList
* @return {FileList}
*/
function arrayFilesToFileList(filesList) {
return filesList.reduce(function (dataTransfer, file) {
dataTransfer.items.add(file);
return dataTransfer;
}, new DataTransfer()).files;
}
/**
* Returns a copy of the Array by swapping 2 indices.
* @param {number} firstIndex
* @param {number} secondIndex
* @param {Array<any>} list
*/
function arraySwapIndex(firstIndex, secondIndex, list) {
const tempList = list.slice();
const tmpFirstPos = tempList[firstIndex];
tempList[firstIndex] = tempList[secondIndex];
tempList[secondIndex] = tmpFirstPos;
return tempList;
}
/*
* Events
*/
// Input file
fileInputMulti.addEventListener("input", async () => {
// Get files list from <input>
const newFilesList = Array.from(fileInputMulti.files);
// Update list files
filesList = await getUniqFiles(newFilesList, filesList);
// Only DEMO. Redraw
renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
// Set data to input
fileInputMulti.files = arrayFilesToFileList(filesList);
});
// Drag and drop
// Drag Start - Moving element.
let myDragElement = undefined;
document.addEventListener("dragstart", (event) => {
// Saves which element is moving.
myDragElement = event.target;
});
// Drag over - Element that is below the element that is moving.
function eventDragOver(event) {
// Remove from all elements the class that will show that it is a drop zone.
event.preventDefault();
multiSelectorUniqPreview
.querySelectorAll("li")
.forEach((item) => item.classList.remove(classDragOver));
// On the element above it, the class is added to show that it is a drop zone.
event.target.classList.add(classDragOver);
}
// Drop - Element on which it is dropped.
function eventDrop(event) {
// The element that is underneath the element that is moving when it is released is captured.
const myDropElement = event.target;
// The positions of the elements in the array are swapped. The dataset key is used as an index.
filesList = arraySwapIndex(
getIndexOfFileList(myDragElement.dataset.key, filesList),
getIndexOfFileList(myDropElement.dataset.key, filesList),
filesList
);
// The content of the input file is updated.
fileInputMulti.files = arrayFilesToFileList(filesList);
// Only DEMO. Changes are redrawn.
renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
}
</script>
</body>
</html>
Agradecimientos
Quiero destacar que sin la ayuda de Valentina Rubane, @varu_nyan en Twitter, no hubiera sido posible. ¡Gracias!
{{ comments.length }} comentarios