Все течет, все меняется, но только input[type=file] как портил нервы всем начинающим веб-разработчикам, так и продолжает это делать до сих пор. Вспомните себя N лет назад, когда вы только начинали постигать азы создания веб-сайтов. Молодой и неопытный, вы искренне удивлялись, когда кнопка выбора файла напрочь отказывалась менять цвет своего фона на ваш любимый персиковый. Именно в тот момент вы впервые столкнулись с этим несокрушимым айсбергом под названием «Загрузка файлов», который и по сей день продолжает «топить» начинающих веб-разработчиков.

На примере создания поля для загрузки файлов я покажу вам, как правильно прятать input[type=file], настраивать фокус на объекте, у которого фокуса быть не может, обрабатывать события Drag-and-Drop и отправлять файлы через AJAX. А также я познакомлю вас с парой браузерных багов и путями их обхода. Статья написана для новичков, но в некоторых моментах может быть полезна и занимательна даже для матерых разработчиков.

Разметка и первичные стили


Начнем с HTML-разметки:

<!DOCTYPE html>
<html lang="en">
<head>
     <meta charset="UTF-8">
     <title>Поле загрузки файлов, которое мы заслужили</title>
     <link rel="stylesheet" href="style.css">
     <script type="text/javascript" src="jquery-3.3.1.min.js"></script>
     <script type="text/javascript" src="script.js"></script>
</head>
<body>
     <form id="upload-container" method="POST" action="send.php">
          <img id="upload-image" src="upload.svg">
          <div>
               <input id="file-input" type="file" name="file" multiple>
               <label for="file-input">Выберите файл</label>
               <span>или перетащите его сюда</span>
          </div>
     </form>
</body>
</html>

Пожалуй, главным элементом, на который стоит обратить внимание, является

<label for="file-input">Выберите файл</label>

Спецификация HTML не позволяет нам накладывать визуальные свойства непосредственно на input[type=file], но мы имеем тэг label, нажатие на который вызывает клик по элементу формы, к которому он привязан. К нашей радости, данный тэг никаких ограничений в стилизации не имеет: мы можем делать с ним все, что захотим.

Вырисовывается план действий: стилизуем метку как нам угодно, а сам input[type=file] прячем с глаз долой. Для начала настроим общие стили страницы:

body {
     padding: 0;
     margin: 0;
     display: flex;
     justify-content: center;
     align-items: center;
     min-height: 100vh;
}

#upload-container {
     display: flex;
     justify-content: center;
     align-items: center;
     flex-direction: column;
     width: 400px;
     height: 400px;
     outline: 2px dashed #5d5d5d;
     outline-offset: -12px;
     background-color: #e0f2f7;
     font-family: 'Segoe UI';
     color: #1f3c44;
}

#upload-container img {
     width: 40%;
     margin-bottom: 20px;
     user-select: none;
}

Теперь стилизуем нашу метку:

#upload-container label {
     font-weight: bold;
}

#upload-container label:hover {
     cursor: pointer;
     text-decoration: underline;
}

То, к чему мы стремимся (input[type=file] убран из разметки):

Безусловно, можно было отцентровать метку, добавить фон и границу, получив полноценную кнопку, но наш приоритет — Drag-and-Drop.

Прячем input


Теперь нам нужно спрятать input[type=file]. Первое, что бросается в голову — свойства display: none и visibility: hidden. Но тут не все так просто. На некоторых старых браузерах клик по метке перестанет производить какой-либо эффект. Но это не все. Как известно, невидимые элементы не могут получать фокус, а кто бы что ни говорил, фокус важен, так как для некоторых людей это единственная возможность взаимодействия с сайтом. Так что этот способ нас не устраивает. Пойдем обходным путем:

#upload-container div {
     position: relative;
     z-index: 10;
}

#upload-container input[type=file] {
     width: 0.1px;
     height: 0.1px;
     opacity: 0;
     position: absolute;
     z-index: -10;
}

Абсолютно спозиционируем наш input[type=file] относительно его родительского блока, уменьшим до 0.1px, сделаем прозрачным и установим его z-index меньше, чем у родителя, чтоб, так сказать, наверняка.

Поздравляю, мы добились того, чего хотели: наше поле выглядит именно так, как на предыдущей картинке.

Настраиваем фокус


Так как наш input[type=file] физически присутствует на страницу, он имеет возможность получать фокус. То есть, если мы будем нажимать на странице клавишу Tab, то в какой-то момент фокус перейдет на input[type=file]. Но проблема в том, что мы этого не увидим: выделяться будет поле, которое мы скрыли. Да, если в этот момент мы нажмем Enter, то диалоговое окно откроется и все будет работать как надо, вот только как мы поймем, что нажимать уже пора?

Наша задача — определенным образом выделить метку в момент, когда фокус расположен на поле загрузки файлов. Но как нам это сделать, если метка получать фокус не может? Знатоки CSS3 сразу же подумают о псевдоклассе :focus, который определяет стили для элементов в фокусе, и селекторах + или ~, которые выбирают правых соседей: элементы, расположенные на том же уровне вложенности, идущие после выбранного элемента. Если учесть, что в нашей разметке input[type=file] расположен прямо перед тэгом label, имеет место быть следующая запись:

#upload-container input[type=file]:focus + label {
     /*Стили для метки*/
}

?Но опять же, не все так просто. Для начала давайте обсудим, каким образом нам следует выделить метку. Как известно, все современные и не очень браузеры имеют уникальные свойства по умолчанию для элементов в фокусе. В основном, это свойство outline, которое создает вокруг элемента обводку, отличающуюся от border тем, что не изменяет размер элемента и может быть отодвинута от него. Как правило, люди пользуются только одним браузером, поэтому привыкают именно к его стандартам. Чтобы людям было проще ориентироваться на нашем сайте, мы должны постараться настроить фокус так, чтобы он выглядел максимально естественно для большинства популярных современных браузеров. В теории, с помощью JavaScript можно получить информацию о том, через какой браузер пользователь открыл сайт, и в соответствии с этим настроить стили, но в рамках статьи, предназначенной в первую очередь для новичков, эта тема слишком сложна и громоздка. Постараемся обойтись малой кровью.

В браузерах, основанных на движке WebKet (Google Chrome, Operа, Safari), свойство по умолчанию для элементов в фокусе имеет вид:

:focus {
     outline: -webkit-focus-ring-color auto 5px;
}

Здесь -webkit-focus-ring-color — специфичный только для данного движка цвет фокусной обводки. То есть, эта строчка будет работать исключительно в WebKit-браузерах, а это именно то, что нам нужно. Укажем данное свойство для нашей метки:

#upload-container input[type=file]:focus + label {
     outline: -webkit-focus-ring-color auto 5px;
}

Открываем Google Chrome или Opera, смотрим. Все работает как надо:

Посмотрим, как обстоят дела с фокусом в Mozilla Firefox и Microsoft Edge. Для этих браузеров свойство по умолчанию имеет вид:

:focus {
     outline: 1px solid #0078d7;
}

и

:focus {
     outline: 1px solid #212121;
}

соответственно.

К сожалению, префикс -moz- со свойством outline работать не будет. Поэтому нам придется выбирать, какое из этих двух свойств мы выберем. Так как количество пользователей Firefox значительно выше, рациональнее отдать предпочтение именно этому браузеру. Это не значит, что мы лишим пользователей Edge и других браузеров возможности видеть, где сейчас фокус, просто он у них будет выглядеть «неродным». Что ж, приходится идти на жертвы.

Добавляем стиль из Mozilla Firefox перед стилем для WebKit: сначала все браузеры применят первое свойство, а затем те, которые могут (Google Chrome, Opera, Safari и др.), применят второе.

#upload-container input[type=file]:focus + label {	
     outline: 1px solid #0078d7;
     outline: -webkit-focus-ring-color auto 5px;
}

И вот тут начинается странное: в Edge все работает нормально, а вот Firefox по каким-то неведомым причинам отказывается применять свойства к метке при фокусе на input[type=file]. Причем само событие focus случается — проверил через JavaScript. Более того, если принудительно установить фокус на поле выбора файла через инструменты разработчика, то свойство применится и наша обводка появится! Видимо, это баг самого браузера, но если у кого-то есть идеи, почему такое происходит — пишите в комментариях.

Ну ничего, нормальные герои всегда идут в обход. Как я сказал ранее, событие focus случается, а значит, регулировать свойства мы можем прямиком из JavaScript. Но для этого нам придется поменять логику нашего селектора:

#upload-container label.focus {
     outline: 1px solid #0078d7;
     outline: -webkit-focus-ring-color auto 5px;
}

Опишем класс .focus для нашей метки и будем добавлять его каждый раз, когда input[type=file] получает фокус и убирать, когда теряет.

$('#file-input').focus(function() {
     $('label').addClass('focus');
})
.focusout(function() {
     $('label').removeClass('focus');
});

Теперь все работает как надо. Поздравляю, с фокусом мы разобрались.

Drag-and-Drop


Работа с Drag-and-Drop осуществляется путем отслеживания специальных браузерных событий: drag, dragstart, dragend, dragover, dragenter, dragleave, drop. Подробное описание каждого из них вы с легкостью сможете найти в интернете. Мы будем отслеживать только некоторые из них.

Для начала определим Drag-and-Drop-элемент:
var dropZone = $('#upload-container');

Затем опишем в CSS специальный класс, который будем присваивать dropZone, когда курсор, тянущий файл, будет прямо над ним. Это нужно, чтобы визуально проинформировать пользователя о том, что файл уже можно отпустить.

#upload-container.dragover {
     background-color: #fafafa;
     outline-offset: -17px;
}

Теперь перейдем в JS-файл. Для начала, нам необходимо отменить все действия по умолчанию на события Drag-and-Drop. Например, одно из таких событий — открытие кинутого файла браузером. Нам это совершенно не нужно, поэтому пропишем следующие строчки:

dropZone.on('drag dragstart dragend dragover dragenter dragleave drop', function(){
     return false;
});

В jQuery вызов оператора return false эквивалентен вызову сразу двух функций: e.preventDefault() и e.stopPropagation().

Начнем описывать свой собственный обработчик событий. Поступим так же, как делали с фокусом, но на этот раз будем отслеживать события dragenter и dragover для добавления класса и событие dragleave для его удаления:

dropZone.on('dragover dragenter', function() {
     dropZone.addClass('dragover');
});

dropZone.on('dragleave', function(e) {
     dropZone.removeClass('dragover');
});

И опять нас ждет неприятный сюрприз: при движении по dropZone мышью с файлом поле начинает мерцать. Происходит это в Microsoft Edge и WebKit-браузерах. Кстати, большинство этих самых WebKit-браузеров в настоящее время работают на движке Blink (оценили иронию, а?). А вот в Mozilla ничего не мерцает. Видимо, решил исправиться после багов с фокусом.

А происходит это мерцание из-за того, что при наведении курсора на дочерний элемент dropZone, будь то картинка или div с полем выбора файлов и меткой, по какой то причине срабатывает событие dragleave. Нам очевидно, что поле мы не покидаем, а вот браузерам, почему-то, нет, и из-за этого они без зазрения совести убирают класс .focus у dropZone.

И вновь нам придется как-то выкручиваться. Если браузер сам не понимает, что поле мы не покидаем, придется ему помочь. А делать мы это будем через дополнительные условия: вычислим координаты мыши относительно dropZone, а затем проверим, вышел ли курсор за пределы блока. Если вышел, значит убираем стиль:

dropZone.on('dragleave', function(e) {
     let dx = e.pageX - dropZone.offset().left;
     let dy = e.pageY - dropZone.offset().top;
     if ((dx < 0) || (dx > dropZone.width()) || (dy < 0) || (dy > dropZone.height())) {
          dropZone.removeClass('dragover');
     };
});

И все, проблема решена! Вот так выглядит наше поле с файлом внутри:


Переходим к обработке самого события drop. Но для начала вспомним, что, помимо Drag-and-Drop, у нас есть input[type=file], и каждый из этих способов независим по своей сути, но должен выполнять одинаковые действия: загружать файлы. Поэтому я предлагаю создать отдельную универсальную для обоих методов функцию, в которую мы будем передавать файлы, а она уже будет решать, что с ними сделать. Назовем ее sendFiles(), но опишем чуть позже. Для начала обработаем событие drop:

dropZone.on('drop', function(e) {
     dropZone.removeClass('dragover');
     let files = e.originalEvent.dataTransfer.files;
     sendFiles(files);
});

Сначала уберем класс .dragover у dropZone. Затем получим массив, содержащий файлы. Если вы используете jQuery, то путь будет e.originalEvent.dataTransfer.files, если пишите на чистом JS, то e.dataTransfer.files. Ну а затем передаем массив в нашу пока еще нереализованную функцию.

Теперь проработаем способ загрузки через input[type=file]:

$('#file-input').change(function() {
     let files = this.files;
     sendFiles(files);
});

Отслеживаем событие change на кнопке выбора файлов, получаем массив через this.files и отправляем его в функцию.

Отправка файлов через AJAX


Последний этап — описание функции обработки файлов — уникален для всех и каждого. Он будет прямым образом зависеть от той цели, которую вы преследуете. Для примера я покажу, как отправлять файлы на сервер через AJAX.

Допустим, мы создаем поле для загрузки фотографий. Мы не хотим, чтобы к нам на сервер попало что-то другое, поэтому определимся с типами файлов: пусть это будут PNG и JPEG. Также стоит регламентировать максимальный размер одной фотографии, которую может отправить пользователь. Ограничимся пятью мегабайтами. Начнем описывать нашу функцию:

function sendFiles(files) {
     let maxFileSize = 5242880;
     let Data = new FormData();
     $(files).each(function(index, file) {
          if ((file.size <= maxFileSize) && ((file.type == 'image/png') || (file.type == 'image/jpeg'))) {
               Data.append('images[]', file);
          }
     });
};

В переменную maxFileSize занесем максимальный размер файла, который будем отправлять на сервер. Функцией FormData() мы создадим новый объект класса FormData, позволяющий формировать наборы пар ключ-значение. Такой объект можно легко отправлять через AJAX. Далее используем jQuery конструкцию .each для массива files, которая применит заданную нами функцию для каждого его элемента. В качестве аргументов в функцию будут передаваться порядковый номер элемента и сам элемент, которые мы будем обрабатывать как index и file соответственно. В самой функции мы проверим файл на соответствие нашим критериям: размер меньше пяти мегабайт, а тип — PNG или JPEG. Если файл проходит проверку, то добавляем его в наш объект FormData путем вызова функции append(). Ключом послужит строка 'photos[]', квадратные скобки на конце которой обозначат, что это массив, в котором может быть несколько объектов. Самим объектом будет file.

Теперь все готово для отправки файлов через AJAX. Добавим в нашу функцию следующие строчки:

$.ajax({
     url: dropZone.attr('action'),
     type: dropZone.attr('method'),
     data: Data,
     contentType: false,
     processData: false,
     success: function(data) {
          alert('Файлы были успешно загружены');
     }
});

В качестве параметров url и type укажем соответственно значения атрибутов action и method у input[type=file]. Передавать через AJAX мы будем объект Data. Параметры contentType: false и processData: false нужны для того, чтобы браузер ненароком не перевел наши файлы в какой-то другой формат. В параметре success укажем функцию, которая выполнится, если файлы успешно передадутся на сервер. Ее содержимое зависит от вашей фантазии, я же ограничусь скромным выводом сообщения об успешной загрузке.

Поздравляю, теперь вы умеете создавать свое собственное поле загрузки файлов! Конечно же, я не позиционирую свой способ как единственно верный и правильный. Своей задачей я ставил показать общий ход решения данной задачи, подходящий в первую очередь для новичков. Если вы считаете, что где-то что-то можно было сделать лучше — пишите в комментариях, обсудим!

На этом все. Спасибо за внимание!

Скачать:

  1. Финальная версия
  2. Проблема с фокусом
  3. Проблема с мерцанием

Пощупать:

  1. Финальная версия
  2. Проблема с фокусом
  3. Проблема с мерцанием

Комментарии (20)


  1. ThisMan
    12.09.2018 13:47
    +1

    Без рабочей демки как то пресно(


    1. RadicalChild Автор
      12.09.2018 15:55
      -2

      Добавил возможность скачать финальный билд, билд с проблемой с фокусом и билд с мерцанием!


      1. RadicalChild Автор
        12.09.2018 18:59

        Теперь добавил возможность пощупать, не бейте!


  1. vlreshet
    12.09.2018 14:46

    Я аж на календарь глянул, думал снова 2012-ый наступил…


    1. fhabr
      12.09.2018 15:30

      А что, с 2012го успел наступить невиданный прорыв в формах передачи файлов?


      1. Fragster
        12.09.2018 16:00

        Jquery протух, все используют habr.com/post/150594.


        1. Akuma
          12.09.2018 17:43

          Да ну, этот фреймворк уже такой старый. Статья 2012 года и в ней написано, что браузеры уже 10 лет его поддерживают. Как минимум 22 года. Дайте человеку попользоваться чем-то современным, стильным и молодежным.


          1. fhabr
            12.09.2018 18:48

            Зачем? Ниже вон вообще рекомендуют не заморачиваться о том как это сделано, а привинтить очередной невелосипед неизвестного качества из хуермиллиона строк кода, нпм и плюшками.


      1. vlreshet
        12.09.2018 16:52

        Да нет, просто уже существует огромная куча готовых решений. Тот же DropzoneJS — наверное, самый известный. Смысл в 2018-ом году писать свой велосипед, да ещё и на jQuery?


    1. RadicalChild Автор
      12.09.2018 16:03

      Действительно, тема стара как мир. Но вместе с ней стары и сопутствующие ей проблемы. Не вижу ничего плохого в том, чтобы «переиздавать» руководства, дополняя их чем-то свежим. Например, толкового объяснения, а уж тем более решения тех багов, на которые я указал в этой статье, я в интернете, даже англоязычном, не нашел.


  1. KYuri
    12.09.2018 15:10

    Ну ничего, нормальные герои всегда идут в обход
    Да, с фокусом тут действительно «в обход».

    Я бы просто добавил в разметку аттрибут «tabindex»:
    <input id="file-input" type="file" name="file" multiple tabindex="-1">
    <label for="file-input" tabindex="0">Выберите файл</label>
    

    И никаких заумных стилей, никакой борьбы с разными браузерами, никаких js-обработчиков…


    1. monochromer
      12.09.2018 15:56

      Только ни пробелом, ни enter'ом на такой label не нажать. Только мышкой.


    1. RadicalChild Автор
      12.09.2018 16:08

      Попробовал, не помогло, но спасибо за вариант! Добавил возможность скачать билды: попробуйте сами, может я что-то не так делаю.


      1. KYuri
        12.09.2018 17:44

        Ваш «финальный», с tabindex-ом.
        Чем не устраивает вариант с tabindex-ом?


        1. RadicalChild Автор
          12.09.2018 18:09

          Да, я все же немного накосячил, но! Фокус действительно будет появляться, вот только, как заметил monochromer выше, нажиматься метка будет только мышью.


  1. psycho-coder
    12.09.2018 15:26

    А мы просто используем plupload.js, который может слать файлы кусками, и не выставлять max_upload_size в сотни МБ.


  1. Nice-L
    12.09.2018 15:59

    Ваши бы слова, да разработчикам ГИС ЖКХ в уши. Шаблоны по одному грузить — рутина. Встречая разработчиков, думающих об удобстве работы даже в таких, казалось бы, «мелочах» как-то даже вера в будущее просыпается.


  1. chelaxe
    12.09.2018 20:07

    Еще есть проблемы в ie9 где нет multiple и когда ajax с файлами таки не отправляется и ты используешь iframe. Еще в ie9 другой объект с файлами, там вроде размера нет и еще что-то, кто помнит напомните. Ох в свое время хлебнул для поддержки ie9 с этим полем загрузки файлов.


  1. monochromer
    13.09.2018 00:15

    Firefox по каким-то неведомым причинам отказывается применять свойства к метке при фокусе на input[type=file].

    Если учитывать относительно современные версии Firefox, то баг можно вылечить с помощью css-псевдокласса :focus-within.


    Сам баг: https://bugzilla.mozilla.org/show_bug.cgi?id=1430196


  1. PaulZi
    13.09.2018 12:30

    Писал подобный велосипед:
    github.com/paulzi/filestyler/blob/master/README.ru.md
    Демо: paulzi.ru/github/filestyler/docs