Componentes de UI
La capa opcional de componentes: Uploader, SlottedUploader, confirmaciones, vista previa, lectura de archivos almacenados, soltar para reemplazar, o nada de ello.
Componentes de UI
Ambos componentes son pieles sobre los hooks — misma validación, compresión,
progreso y abort. Importa uploaderkit/tailwind.css una vez (ver
Configuración de Tailwind v4).
Uploader
Una zona de drop, uno o varios archivos dentro.
import { Uploader } from 'uploaderkit/ui'
;<Uploader
scopes={scopes}
scope='invoice-evidence'
entityId={invoiceId}
strategy={strategy}
multiple
maxFiles={3}
label='Evidencia'
description='PDF o foto, hasta 8 MB'
stored={saved} // ya persistidos, del lado que diga filesPosition
filesPosition='below' // que la zona de drop no se deslice hacia abajo
onRemoveStored={forget} // borrar en remoto sigue siendo decisión tuya
confirmRemove // segundo paso en diálogo; o { title, message }
onUploaded={persist}
resolveViewUrl={file => api.signedUrl(file.key)}
capture='environment' // móvil: abre la cámara trasera directo
/>Acepta todas las opciones de useUploader más las props de presentación de
arriba, y pone uploadOn en 'select' por defecto. 'manual' le da a la
zona un botón de subida para los archivos que esperan; 'submit' entrega el
envío a tu formulario a través de controllerRef (ver
Disparo de la subida).
resolveViewUrl vuelve a firmar un objeto privado justo antes de
previsualizarlo, para el caso en que la URL guardada ya expiró.
filesPosition decide de qué lado de la zona de drop se acomodan las listas de
archivos. Por defecto 'above', el layout de siempre; 'below' mantiene la
zona anclada, lo que importa cuando los archivos se agregan de a uno — si no,
cada agregado empuja hacia abajo el blanco al que el usuario está apuntando.
renderFiles reemplaza las filas mismas. Recibe los archivos persistidos, los
de la sesión con su estado vivo, las filas default ya construidas y los
callbacks de ver/quitar — así una pantalla renderiza un grid de miniaturas, una
línea de resumen o un conteo dentro de su propia tarjeta:
<Uploader
{...props}
filesPosition='below'
renderFiles={({ staged, stored, isEmpty, remove }) =>
isEmpty ? null : (
<ul className='grid grid-cols-3 gap-2'>
{stored.map(file => (
<li key={file.key}>{file.fileName}</li>
))}
{staged.map(file => (
<li key={file.id} onClick={() => remove(file.id)}>
{file.file.name} · {file.status}
</li>
))}
</ul>
)
}
/>Cambia las filas, no su lugar: el resultado sigue renderizando del lado de
filesPosition. Para un layout del que la zona misma es parte — archivos AL
LADO de la zona, todo dentro de tu propio marco — sáltate esta piel y compón
useUploader con los Dropzone, FileItem y StoredFileItem exportados.
Nada de aquí falta allá.
La zona también acepta un archivo pegado mientras tiene el foco (los
screenshots aterrizan como subidas), y capture hace que un dispositivo táctil
ofrezca su cámara en vez del picker. En un pointer coarse el prompt cambia al
copy de tap (labels.tapPrompt) con feedback de presión — un usuario de
celular nunca lee sobre arrastrar.
Perillas de presentación: size='sm' compacta la zona y todas las filas;
icon reemplaza el glifo de la zona con cualquier nodo (icon={null} lo
quita). Colores y radios salen de las variables del tema — ver
Theming.
shortcut='mod+u' liga una tecla global (⌘U / Ctrl+U) que abre el picker y
renderiza un hint kbd pequeño dentro de la zona, para que el usuario lo
descubra. Nunca dispara mientras se escribe en un campo, y dos zonas con el
mismo combo avisan en desarrollo — con varios uploaders en pantalla, dale a
cada uno el suyo.
SlottedUploader
Una fila de estado por slot más una zona de drop masiva cuyo matcher rutea cada archivo:
import { SlottedUploader } from 'uploaderkit/ui'
;<SlottedUploader
scopes={scopes}
scope='company-identity'
entityId={companyId}
strategy={strategy}
title='Documentos de la empresa'
slots={[
{ id: 'letterhead', label: 'Hoja membretada', extensions: ['pdf'] },
{
id: 'logo',
label: 'Logo',
extensions: ['png', 'svg'],
hint: 'Fondo transparente',
},
]}
value={slotFiles}
onChange={setSlotFiles}
confirmRemove // diálogo antes de olvidar un slot lleno
confirmReplace // diálogo que nombra ambos archivos antes de sobrescribir
hideDropzone={false}
/>confirmReplace intercepta el archivo elegido después del pick — así el
diálogo puede nombrar lo que se va a perder y lo que lo reemplaza. Ambas props
aceptan true para la copia por defecto o { title, message } para
sobrescribirla.
Filas en espera. Con uploadOn: 'submit' el archivo elegido no viaja:
descansa en su fila con miniatura, nombre y listo para subir, más Reemplazar y
Quitar, hasta que el formulario llama a controllerRef.current.upload(). El
punto de estado se pone ámbar para decirlo. El nombre que se muestra es el de
almacenamiento — el archivo se renombra a {slot}.{ext} antes de entrar a la
máquina, y es el que la entidad va a servir.
Quitar borra; el historial se declara. Pasa un removeStrategy — createRemoveStrategy({ endpoint, headers, credentials }), el espejo DELETE del transporte de subida (DELETE {endpoint}/{scope}/{entityId} con { key }) — y un quitar confirmado borra el objeto del storage por sí solo, igual en Uploader que en SlottedUploader. onRemoveStored(stored) sigue disparándose para la contabilidad de la app (limpiar la referencia en DB), entregado ANTES del onChange. La referencia se olvida aunque el borrado falle — un puntero colgante es peor que un huérfano — y un borrado rechazado sale por onError. La excepción es contrato del scope, no decisión del cliente: márcalo keepOnRemove: true y tanto los componentes saltan el borrado como el storage.remove del server responde false — historial aplicado donde ningún cliente lo puede saltar.
Idioma. El default del kit es inglés. Una app en español opta una sola vez en la raíz — <UploaderProvider language='es'> (exportado de /react y /ui) — y todo componente y hook debajo, incluidos los mensajes de validación como maxFilesReached, habla español; el prop labels por componente sigue ganando para reescrituras puntuales.
Confirmaciones
Las acciones destructivas sobre archivos llevan un segundo paso: un diálogo
accesible (portal, foco atrapado, el foco cae en cancelar para que un
Enter perdido nunca destruya nada; Escape y el fondo cancelan).
ConfirmDialog se exporta para envolver tus propias acciones en la misma UX:
import { ConfirmDialog } from 'uploaderkit/ui'
;<ConfirmDialog
open={confirming}
title='Eliminar expediente'
message='Se borrarán también sus documentos.'
variant='danger'
onConfirm={destroy}
onCancel={() => setConfirming(false)}
/>Vista previa (FileViewer)
Ambos uploaders integran el visor de pantalla completa; se exporta standalone
para cualquier pantalla que persista un StoredFile. Hace portal a <body>
(ningún stacking context ancestro puede atraparlo), atrapa el foco mientras
está abierto y lo restaura al cerrar, y bloquea el scroll de la página detrás.
Cuando resolveUrl falla — una firma expirada, una conexión caída — el visor
muestra un error con botón de reintento en vez de cargar por siempre. En
viewports angostos un PDF se renderiza como tarjeta de descarga en vez de un
frame embebido (iOS Safari congela los PDF embebidos).
Pasa files (la colección) junto a file (el que se clickeó) y el visor se
vuelve galería: flechas laterales, ←/→ en el teclado, contador 2 / 5 y —
en pointers finos — un pie que muestra los atajos (Esc, ← →), para que
nadie tenga que adivinarlos:
<FileViewer file={viendo} files={imagenesGuardadas} onClose={cerrar} />Teclado: Esc cierra, ←/→ recorren la galería, D descarga y O abre el
archivo en pestaña — cada uno anunciado en el pie con pointers finos. Nunca se
reclama una tecla con modificador, así que ⌘D sigue guardando en marcadores.
renderError reemplaza el panel de "no se pudo cargar" integrado. Recibe el
archivo que falló más un retry que vuelve a resolverlo, para que un panel
propio conserve la recuperación que da el default:
<FileViewer
file={viendo}
onClose={cerrar}
renderError={({ file, retry }) => (
<MiPanelDeError name={file.fileName} onRetry={retry} />
)}
/>useFileViewer es dueño del estado abrir/cerrar que si no repetirías en cada
pantalla:
const viewer = useFileViewer({ resolveUrl })
<button onClick={() => viewer.open(stored)}>Ver</button>
<FileViewer {...viewer.viewerProps} />Leer un archivo guardado
Un <img> o un <iframe> no pueden mandar header Authorization, así que un
objeto privado o cifrado nunca renderiza desde su url cruda. Dos helpers hacen
la lectura autenticada por ti — misma regla, dos formatos de salida:
import {
createBlobUrlResolver,
createBytesResolver,
} from 'uploaderkit/react'
// Para el visor: hace fetch con los headers de la app y devuelve un object URL.
const resolveViewUrl = createBlobUrlResolver({
baseUrl: apiUrl,
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
credentials: 'include',
})
<Uploader {...props} resolveViewUrl={resolveViewUrl} />
// Para código que procesa el archivo en vez de mostrarlo.
const readBytes = createBytesResolver({ baseUrl: apiUrl, headers })
const pdf = await PDFDocument.load(await readBytes(stored.url))La regla que comparten es de origen, no de forma: una url que sirve
baseUrl — relativa a la app, o absoluta en el mismo origen — es tuya y viaja
con tus headers y tus credentials; una en un origen ajeno ya es
alcanzable y se pide pelada, porque el token nunca debe ir a un host de
terceros. El encryptedUrl de tu servidor normalmente persiste una url
absoluta que apunta de vuelta a tu propia ruta /view: esa cuenta como tuya. Leer los bytes por tu propio endpoint es además
lo que le ahorra a un bucket público su propia política de CORS: un <img>
está exento de CORS, un fetch por bytes no.
viewUrlFileName(url) recupera el nombre visible de una url /view?key=….
Soltar para reemplazar
Una fila llena del SlottedUploader es en sí misma un drop target: arrastrar
un archivo encima la ilumina con la pill “Suelta para reemplazar”,
y el drop pasa por el mismo diálogo de confirmReplace que el botón. Una fila
vacía acepta el drop como llenado directo — sin pasar por el matcher de la
zona masiva.
Headless por completo
Una app con su propio design system usa /react directo y no pierde nada — la
validación, la compresión, el progreso, el abort y el ruteo de slots viven en
los hooks. /ui existe para que una pantalla que no necesita markup propio no
tenga que escribirlo.