FileUpload
FileUpload renderiza seleção de arquivos, propostas de validação, estados do ciclo de vida, ações e
anúncios. Sua aplicação é dona do array items e do transporte. Lyra nunca inicia uma requisição,
avança o progresso, deduz sucesso nem remove um item por conta própria.
Exemplos
Transporte controlado real
Este exemplo completo ecoa no reducer as identidades propostas pelo Lyra, retém o File
selecionado e o envia com XMLHttpRequest. O progresso vem de XMLHttpRequest.upload; um
AbortController controla cada tentativa, e o reducer rejeita resultados cujo attemptId não é
mais o atual.
Galeria de estados controlada
A galeria renderiza itens selecionados, em upload determinado e indeterminado, cancelando, com sucesso, com erro de transporte repetível, com erro de validação e cancelados. Seus controles confirmam mudanças de estado visíveis, mas nenhum transporte é iniciado.
- brief.pdf
- photos.zip
- interview.wav
- draft.mov
- contract.pdf
- catalog.pdf
- archive.exe
- research.pdf
Responsabilidade controlada
items é a única fonte de verdade renderizada. onSelect recebe objetos File reais junto das
identidades propostas para item e tentativa. Ecoe cada proposedItem aceito antes de iniciar seu
transporte e reutilize proposedAttemptId ao confirmar uploading. Uma falha de validação também é
uma proposta: ecoe-a para exibir o erro ou ignore-a para rejeitar o arquivo sem criar uma linha.
Trocar a identidade proposta por outra não é suportado.
Retry propõe uma nova identidade de tentativa. Vincule cada resultado assíncrono à identidade que o
iniciou e descarte progresso, sucesso, erro ou confirmação de aborto vindos de tentativas antigas.
Uma intenção de cancelamento não significa que a requisição já foi cancelada: primeiro confirme
canceling, aborte o transporte correspondente e confirme canceled apenas quando o transporte
confirmar o aborto. Um sucesso ou erro que vencer essa corrida ainda pode ser confirmado e será
renderizado de forma fiel.
A remoção segue a mesma regra controlada. onRemove é uma intenção; a linha permanece até que o
próximo valor de items omita seu ID. Depois dessa confirmação, Lyra restaura o foco para uma ação
disponível próxima ou para o input nativo. Retry, cancelamento e remoção permanecem desabilitados
enquanto a intenção atual está pendente, evitando ativações duplicadas.
Formulários nativos e melhoria progressiva
A área visível é um <label> real associado a um <input type="file"> focalizável. Com name,
Lyra retém arquivos válidos selecionados por esse input e sincroniza itens locais confirmados de
volta para input.files; assim, o FormData reflete remoções. Um item inserido externamente não
possui um objeto File do navegador e não pode ser sintetizado no envio nativo; quando necessário,
envie seu identificador do servidor em outro campo. Sem name, a seleção serve apenas ao transporte
e não cria entrada no formulário.
O markup renderizado pelo servidor e a primeira renderização React mantêm as mesmas relações do input, estados, atributos de progresso e região viva vazia. Antes do JavaScript, o input rotulado continua permitindo seleção e envio nativos. Arrastar e soltar, atualizações ao vivo, retry, cancelamento e remoção são melhorias e não devem aparecer como controles inativos sem JavaScript.
Anúncios e foco
Lyra anuncia seleção, validação, cancelamento em andamento, sucesso, erros de transporte, upload cancelado e remoção confirmada por uma única região viva persistente e polida. O progresso determinado é anunciado apenas ao cruzar 25%, 50%, 75% ou 100%, não a cada evento. Tentativas antigas não substituem o estado visível nem geram anúncio. O seletor nativo e todas as ações mantêm foco visível; remover a linha em foco só move o foco após a remoção controlada ser confirmada.
API e adaptadores
Blade: O Blade v0.10.0 ainda expõe o contrato de upload anterior. Use React ou Alpine até uma versão do Blade comprovar o ciclo de vida controlado.
| Nome | Tipo | Obrigatória | Descrição |
|---|---|---|---|
items | readonly FileUploadItem[] | Obrigatória | |
onSelect | (intent: FileUploadSelectIntent) => void | Obrigatória | |
onRetry | (intent: FileUploadRetryIntent) => void | Obrigatória | |
onCancel | (intent: FileUploadCancelIntent) => void | Obrigatória | |
onRemove | (intent: FileUploadRemoveIntent) => void | Obrigatória | |
name | string | — | |
accept | string | — | |
maxSizeMB | number | — | |
multiple | boolean | — | |
disabled | boolean | — | |
required | boolean | — | |
label | string | — | |
hint | string | — | |
messages | FileUploadMessages | — |
As mensagens de callback do React podem ser funções localizadas. FileUpload encaminha uma ref
para sua raiz e mantém o input nativo habilitado durante o bloqueio temporário de substituição de
arquivo único, preservando a participação no formulário.
x-data="lyraFileUpload({ … })"
| Opção | Tipo | Obrigatória | Descrição |
|---|---|---|---|
items | LyraFileUploadItem[] | — | |
name | string | — | |
accept | string | — | |
maxSizeMB | number | — | |
multiple | boolean | — | |
disabled | boolean | — | |
required | boolean | — | |
messages | LyraFileUploadMessages | — |
O controller Alpine pai attachmentUpload abaixo é dono de items, do transporte real, da política
de aborto e das verificações contra tentativas antigas. x-model envia toda substituição confirmada
de volta pelo caminho único de reconciliação do Lyra. Os quatro eventos são notificações de
intenção; nenhum inicia ou conclui transporte dentro do Lyra.
document.addEventListener('alpine:init', () => {
Alpine.data('attachmentUpload', () => ({
uploadItems: [],
files: new Map(),
controllers: new Map(),
statusLabels: {
selected: 'Selecionado',
uploading: 'Enviando',
canceling: 'Cancelando',
success: 'Concluído',
error: 'Falhou',
canceled: 'Cancelado',
},
updateAttempt(id, attemptId, statuses, update) {
this.uploadItems = this.uploadItems.map((item) =>
item.id === id && item.attemptId === attemptId && statuses.includes(item.status)
? update(item)
: item,
);
},
uploadProgress(event) {
if (event.lengthComputable && event.total > 0) {
return {
kind: 'determinate',
value: Math.min(100, Math.max(0, Math.round((event.loaded / event.total) * 100))),
};
}
return { kind: 'indeterminate' };
},
beginUpload(file, id, attemptId) {
const source = this.uploadItems.find((item) => item.id === id);
const canStart =
source &&
(source.status === 'selected' ||
source.status === 'canceled' ||
(source.status === 'error' &&
source.error.kind === 'transport' &&
source.error.retryable));
if (!canStart) {
throw new Error(`O upload ${id} não está pronto para uma nova tentativa.`);
}
this.uploadItems = this.uploadItems.map((item) =>
item.id === id
? {
id: item.id,
name: item.name,
size: item.size,
type: item.type,
status: 'uploading',
attemptId,
progress: { kind: 'indeterminate' },
}
: item,
);
const controller = new AbortController();
const request = new XMLHttpRequest();
const abortRequest = () => request.abort();
this.controllers.set(attemptId, controller);
controller.signal.addEventListener('abort', abortRequest, { once: true });
request.upload.addEventListener('progress', (event) => {
this.updateAttempt(id, attemptId, ['uploading', 'canceling'], (item) => ({
...item,
progress: this.uploadProgress(event),
}));
});
request.addEventListener('load', () => {
if (request.status >= 200 && request.status < 300) {
this.updateAttempt(id, attemptId, ['uploading', 'canceling'], (item) => ({
id: item.id,
name: item.name,
size: item.size,
type: item.type,
status: 'success',
attemptId,
}));
return;
}
this.failUpload(id, attemptId, `O servidor rejeitou o upload (${request.status}).`);
});
request.addEventListener('error', () => {
this.failUpload(
id,
attemptId,
'Não foi possível conectar ao servidor para enviar o arquivo.',
);
});
request.addEventListener('abort', () => {
this.updateAttempt(id, attemptId, ['canceling'], (item) => ({
id: item.id,
name: item.name,
size: item.size,
type: item.type,
status: 'canceled',
attemptId,
}));
});
request.addEventListener('loadend', () => {
controller.signal.removeEventListener('abort', abortRequest);
if (this.controllers.get(attemptId) === controller) {
this.controllers.delete(attemptId);
}
});
request.open('POST', '/api/uploads');
request.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
request.setRequestHeader('X-File-Name', encodeURIComponent(file.name));
request.send(file);
},
failUpload(id, attemptId, message) {
this.updateAttempt(id, attemptId, ['uploading', 'canceling'], (item) => ({
id: item.id,
name: item.name,
size: item.size,
type: item.type,
status: 'error',
attemptId,
error: { kind: 'transport', message, retryable: true },
}));
},
startUploads({ selections }) {
this.uploadItems = [
...this.uploadItems,
...selections.map(({ proposedItem }) => proposedItem),
];
for (const selection of selections) {
if (typeof selection.proposedAttemptId !== 'string') continue;
this.files.set(selection.id, selection.file);
this.beginUpload(selection.file, selection.id, selection.proposedAttemptId);
}
},
retryUpload({ id, proposedAttemptId }) {
const file = this.files.get(id);
if (!file) throw new Error(`Não há um arquivo local para repetir o upload ${id}.`);
this.beginUpload(file, id, proposedAttemptId);
},
cancelUpload({ id, attemptId }) {
const controller = this.controllers.get(attemptId);
if (!controller) throw new Error(`Não há transporte ativo para o upload ${id}.`);
this.updateAttempt(id, attemptId, ['uploading'], (item) => ({
...item,
status: 'canceling',
}));
controller.abort();
},
removeUpload({ id }) {
this.files.delete(id);
this.uploadItems = this.uploadItems.filter((item) => item.id !== id);
},
}));
});<form x-data="attachmentUpload" method="post" enctype="multipart/form-data">
<div
id="attachment-upload"
class="lyra-upload"
data-state="idle"
x-data="lyraFileUpload({
name: 'attachments[]',
accept: 'image/*,.pdf',
maxSizeMB: 10,
multiple: true,
messages: {
selectionUnavailable: 'A substituição de arquivo fica indisponível enquanto há um upload ativo.',
validationAccept: '{name} precisa corresponder a {accept}.',
validationMaxSize: '{name} não pode exceder {maxSizeMB} MB.',
selected: '{name} selecionado.',
progress: 'Upload de {name} em {percent}%.',
progressIndeterminate: 'Enviando {name}.',
canceling: 'Cancelando {name}.',
success: '{name} enviado.',
error: '{name}: falha no upload.',
canceled: 'Upload de {name} cancelado.',
removed: '{name} removido.',
retry: 'Tentar novamente: {name}',
cancel: 'Cancelar {name}',
remove: 'Remover {name}'
}
})"
x-modelable="items"
x-model="uploadItems"
@lyra:file-upload:select="startUploads($event.detail)"
@lyra:file-upload:retry="retryUpload($event.detail)"
@lyra:file-upload:cancel="cancelUpload($event.detail)"
@lyra:file-upload:remove="removeUpload($event.detail)"
>
<label class="lyra-upload__zone" for="attachment-upload-input" x-bind="zone">
<span class="lyra-upload__zone-icon" aria-hidden="true"></span>
<span class="lyra-upload__zone-label">Escolha anexos</span>
<span class="lyra-upload__zone-hint">Imagens ou PDF, até 10 MB por arquivo</span>
</label>
<input
id="attachment-upload-input"
class="lyra-upload__input"
type="file"
name="attachments[]"
accept="image/*,.pdf"
multiple
x-bind="input"
/>
<ul class="lyra-upload__list">
<template x-for="item in items" :key="item.id">
<li class="lyra-upload__item" x-bind="itemBindings(item)">
<span class="lyra-upload__item-body">
<span class="lyra-upload__item-row">
<span class="lyra-upload__item-name" x-text="item.name"></span>
<span
class="lyra-upload__item-meta"
x-text="item.status === 'error' ? item.error.message : statusLabels[item.status]"
></span>
</span>
<template x-if="item.status === 'uploading' || item.status === 'canceling'">
<progress class="lyra-upload__bar" x-bind="progressBindings(item)"></progress>
</template>
</span>
<template x-if="item.status === 'uploading'">
<button class="lyra-upload__cancel" x-bind="actionBindings('cancel', item)">
Cancelar
</button>
</template>
<template
x-if="item.status === 'canceled' || (item.status === 'error' && item.error.retryable)"
>
<button class="lyra-upload__retry" x-bind="actionBindings('retry', item)">
Tentar novamente
</button>
</template>
<template
x-if="item.status === 'selected' || item.status === 'success' || item.status === 'canceled' || item.status === 'error'"
>
<button class="lyra-upload__remove" x-bind="actionBindings('remove', item)">
Remover
</button>
</template>
</li>
</template>
</ul>
<span
class="lyra-upload__live lyra-visually-hidden"
aria-live="polite"
aria-atomic="true"
x-bind="liveRegion"
></span>
</div>
<button type="submit">Enviar</button>
</form>A raiz precisa de um id único criado no servidor. Renderize no servidor o label, as restrições do
input, o conteúdo conhecido de item/status e a região viva vazia. Coloque botões de operação dentro
dos templates Alpine para que não se tornem controles inativos antes da melhoria. Mensagens Alpine
são strings serializáveis e aceitam {name}, {percent}, {accept} e {maxSizeMB}.
O Blade v0.10.0 ainda implementa o comportamento anterior de upload e, por isso, está intencionalmente adiado neste ciclo de vida. O aviso de ausência do Blade nesta página vem dos metadados de suporte do componente e permanecerá até uma versão Blade comprovar o mesmo contrato controlado.