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 removeStrategycreateRemoveStrategy({ 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.

On this page