Aller au contenu

FileInput

composant

A file field that also takes a drop, listing what it holds with the size of each.

Aperçu

Clair

    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" />

    Sombre

      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" />

      Essayer

      Changez les propriétés ; le serveur rend l’exemple de nouveau.

        <twig:FileInput name="photos[]" label="Add photos" hint="PNG or JPEG, up to 2 MB" accept="image/png,image/jpeg" :maxSize="2097152" :multiple="true" />

        Variantes

        multiple

        Whether several files can be given.

            multiple="false"

                multiple="true"

                required

                Whether a file has to be given.

                    required="false"

                        required="true"

                        disabled

                        Whether the field cannot be changed for now.

                            disabled="false"

                                disabled="true"

                                Installation

                                php bin/console ux:install file-input --kit=github.com/parler-haut-interagir-librement/ux-kit

                                Propriétés

                                PropriétéTypePar défautDescription
                                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.

                                Fichiers à copier

                                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>