FileInput
componentA file field that also takes a drop, listing what it holds with the size of each.
Preview
Code
<twig:FileInput name="photos[]" label="Add photos" hint="PNG or JPEG, up to 2 MB" accept="image/png,image/jpeg" :maxSize="2097152" :multiple="true" />
Code
<twig:FileInput name="photos[]" label="Add photos" hint="PNG or JPEG, up to 2 MB" accept="image/png,image/jpeg" :maxSize="2097152" :multiple="true" />
Try it
Change the props; the example is rendered again by the server.
<twig:FileInput name="photos[]" label="Add photos" hint="PNG or JPEG, up to 2 MB" accept="image/png,image/jpeg" :maxSize="2097152" :multiple="true" />
<twig:FileInput name="photos[]" label="Add photos" hint="PNG or JPEG, up to 2 MB" accept="image/png,image/jpeg" :maxSize="2097152" :multiple="true" />
Variants
multiple
Whether several files can be given.
required
Whether a file has to be given.
disabled
Whether the field cannot be changed for now.
Installation
php bin/console ux:install file-input --kit=github.com/parler-haut-interagir-librement/ux-kit
Props
| Prop | Type | Default | Description |
|---|---|---|---|
hint |
string|null |
null |
A second line in the zone: the types and the size expected. |
id |
string|null |
null |
The identifier of the field. |
accept |
string|null |
null |
The types taken, as the attribute accept writes them. |
multiple |
bool |
false |
Whether several files can be given. |
maxSize |
int |
0 |
The largest file taken, in bytes; 0 for no limit. |
rejectedLabel |
string |
'Files left out: {count}' |
What is said of the files left out, {count} replaced. |
clearLabel |
string |
'Remove' |
The button that empties the field. |
required |
bool |
false |
Whether a file has to be given. |
disabled |
bool |
false |
Whether the field cannot be changed for now. |
Accessibility
The input stays the field and its label the zone: Tab reaches it and Enter opens the dialog. What accept, maxSize or a single field leaves out is said in a polite live region. These limits spare the reader a useless upload; only the server can enforce them. File names are written as text.
Files to copy
assets/controllers/file_input_controller.js
import { Controller } from '@hotwired/stimulus';
/** Whether a file is of the types the attribute `accept` writes: a `.extension`, a `type/*` or a whole media type. */
export function accepted(file, accept) {
if (accept === '') {
return true;
}
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
return accept
.toLowerCase()
.split(',')
.map((token) => token.trim())
.some((token) => {
if (token.startsWith('.')) {
return name.endsWith(token);
}
return token.endsWith('/*') ? type.startsWith(token.slice(0, -1)) : type === token;
});
}
/** A size in the unit that suits it, in the language of the page. */
export function sizeText(bytes, locale) {
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'];
const power = Math.min(units.length - 1, Math.floor(Math.log(Math.max(bytes, 1)) / Math.log(1024)));
return new Intl.NumberFormat(locale, { style: 'unit', unit: units[power], maximumFractionDigits: 1 }).format(
bytes / 1024 ** power,
);
}
/*
* The list of what a file field holds, and the drop on its zone. Whatever comes in, by the dialog or by a drop, goes
* through the same sieve: the types, the size and the number the field takes.
*/
export default class extends Controller {
static targets = ['zone', 'input', 'list', 'error', 'clear'];
static classes = ['item', 'dragging'];
static values = {
maxSize: { type: Number, default: 0 },
rejectedLabel: String,
};
over(event) {
event.preventDefault();
if (!this.inputTarget.disabled) {
this.zoneTarget.classList.add(...this.draggingClasses);
}
}
leave() {
this.zoneTarget.classList.remove(...this.draggingClasses);
}
drop(event) {
event.preventDefault();
this.leave();
if (this.inputTarget.disabled) {
return;
}
this.inputTarget.files = this.#transfer([...event.dataTransfer.files]);
this.inputTarget.dispatchEvent(new Event('change', { bubbles: true }));
}
list() {
const input = this.inputTarget;
const given = [...input.files];
const fitting = given.filter(
(file) => accepted(file, input.accept) && (this.maxSizeValue === 0 || file.size <= this.maxSizeValue),
);
const kept = input.multiple ? fitting : fitting.slice(0, 1);
if (kept.length !== given.length) {
input.files = this.#transfer(kept);
}
this.errorTarget.textContent =
kept.length === given.length
? ''
: this.rejectedLabelValue.replace('{count}', String(given.length - kept.length));
this.#show(kept);
}
clear() {
this.inputTarget.value = '';
this.errorTarget.textContent = '';
this.#show([]);
this.inputTarget.dispatchEvent(new Event('change', { bubbles: true }));
this.inputTarget.focus();
}
#transfer(files) {
const transfer = new DataTransfer();
for (const file of files) {
transfer.items.add(file);
}
return transfer.files;
}
#show(files) {
const locale = document.documentElement.lang || undefined;
this.listTarget.replaceChildren(
...files.map((file) => {
const item = document.createElement('li');
item.classList.add(...this.itemClasses);
item.textContent = `${file.name} (${sizeText(file.size, locale)})`;
return item;
}),
);
this.clearTarget.hidden = files.length === 0;
}
}
templates/components/FileInput.html.twig
{#-
# A file field that also takes a drop: the input stays the field, its label the zone. What the browser picked is
# listed with its size; what the props refuse is left out and said aloud. The server decides in the end.
#}
{%- props
## string The name of the field.
name,
## string What the zone says.
label,
## string|null A second line in the zone: the types and the size expected.
hint = null,
## string|null The identifier of the field.
id = null,
## string|null The types taken, as the attribute `accept` writes them.
accept = null,
## bool Whether several files can be given.
multiple = false,
## int The largest file taken, in bytes; 0 for no limit.
maxSize = 0,
## string What is said of the files left out, `{count}` replaced.
rejectedLabel = 'Files left out: {count}',
## string The button that empties the field.
clearLabel = 'Remove',
## bool Whether a file has to be given.
required = false,
## bool Whether the field cannot be changed for now.
disabled = false
-%}
<div data-file-input-max-size-value="{{ maxSize }}" data-file-input-rejected-label-value="{{ rejectedLabel }}" data-file-input-item-class="isFileInputItem" data-file-input-dragging-class="isDragging" data-action="dragover->file-input#over dragleave->file-input#leave drop->file-input#drop"{{ attributes.defaults({class: 'isFileInput', 'data-controller': 'file-input'}) }}>
<label class="isFileInputZone" data-file-input-target="zone">
<input class="isFileInputField" type="file" name="{{ name }}"{% if id is not null %} id="{{ id }}"{% endif %}{% if accept is not null %} accept="{{ accept }}"{% endif %}{% if multiple %} multiple{% endif %}{% if required %} required{% endif %}{% if disabled %} disabled{% endif %} data-file-input-target="input" data-action="change->file-input#list">
<span class="isFileInputLabel">{{ label }}</span>
{%- if hint is not null -%}<span class="isFileInputHint">{{ hint }}</span>{%- endif -%}
</label>
<ul class="isFileInputList" data-file-input-target="list"></ul>
<p class="isFileInputError" role="status" aria-live="polite" data-file-input-target="error"></p>
<button type="button" class="isFileInputClear" hidden data-file-input-target="clear" data-action="file-input#clear">{{ clearLabel }}</button>
</div>