Перейти к содержимому
Плотность
Тема

Ввод

Поле файла

JS Перетаскивание файлов и список выбранного — слой приложения. Выбор через диалог работает без скрипта

Выбор файла и зона, в которую его кладут, — один компонент, а не два. Пунктирная рамка означает «сюда можно положить»; нативная кнопка выбора спрятана и заменена всей площадью зоны.

Компонент
Импорт списка получателей
Разметка
<div class="inst-field">
  <span class="inst-label">Импорт списка получателей</span>
  <label class="inst-file">
    <input type="file" multiple>
    Перетащите файлы или выберите
    <span class="inst-file-hint">До 20 МБ, форматы .json и .csv</span>
  </label>
</div>

Использование

<label class="inst-file">
  <input type="file">
  Выберите файл
  <span class="inst-file-hint">PNG или SVG, до 2 МБ</span>
</label>
Что Обязательно Почему
<label> как контейнер да Нажатие в любое место зоны открывает диалог. Обёртка <div> оставила бы кликабельной только спрятанную кнопку
<input type="file"> прямым ребёнком да Состояния фокуса и недоступности написаны через :has(> input…)
Текст-призыв прямо в <label> да Это доступное имя поля. Без него скринридер прочитает «файл, кнопка»
Никакого display: none на поле да Поле уведено в клип и остаётся в порядке обхода
inst-file-hint нет Ограничения формата и размера. Пишите их до выбора, а не в сообщении об ошибке после
accept на <input> нет, но обычно да Системный диалог отфильтрует по типу. Это подсказка, а не валидация: проверять всё равно на сервере

Само поле не спрятано display: none, а уведено в клип (clip-path) с размером 1×1: спрятанное через display или visibility поле выпадает из порядка обхода, и зона перестаёт достигаться клавиатурой.

Пунктир здесь — единственное место в ките, где линия не сплошная, и это осознанно: другого настолько же понятного знака «сюда можно положить» нет.

Когда использовать

Используйте Возьмите другое
Загрузка одного или нескольких файлов Вставка текста, который у пользователя уже в буферетекстовое поле, многострочное: заставлять сохранять буфер в файл дороже, чем принять текст
Импорт, у которого есть требования к формату и размеру Выбор из файлов, уже загруженных раньшеселект или таблица со списком
Место, куда файл естественно перетащить Действие «экспортировать» — это кнопка, а не зона
Зона во всю ширину блока формы Файл среди десятка полей в плотной форме — узкая зона в 40px не читается как зона; выносите импорт отдельным блоком

Состояния

Пример
Разметка
<label class="inst-file">
  <input type="file">
  Перетащите файл или выберите
  <span class="inst-file-hint">До 20 МБ</span>
</label>
<label class="inst-file">
  <input type="file" disabled>
  Импорт отключён администратором
  <span class="inst-file-hint">Обратитесь к владельцу пространства</span>
</label>
Состояние Как ставится Что происходит
наведение :hover Рамка --accent-border, фон --accent-bg — зона отзывается до нажатия
фокус :has(> input:focus-visible) Кольцо вокруг всей зоны
недоступно :has(> input:disabled) Прозрачность 0.5, курсор not-allowed

Состояния «файл над зоной» в ките нет: оно возникает только по событию перетаскивания, то есть по JS. Приложение вешает свой класс и красит им те же две переменные, что использует :hover.

Поведение

Перетаскивание — это JS. Кит рисует статическую часть: пунктирную зону, значок, подсветку на наведении и фокусе. Обработка dragover и drop, подсветка «файл над зоной», список выбранного, прогресс отправки и ошибки — код приложения.

Без единой строки скрипта компонент остаётся рабочим: это <label> вокруг input[type=file], нажатие открывает системный диалог, выбор уходит с формой. Зона перетаскивания — улучшение поверх работающего, а не условие.

Для прогресса отправки берите меру, для отчёта об ошибке — баннер или сноску.

JS

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

<script type="module" src="src/kit.js"></script>

Что делает кит

Ничего. Зона рисует состояние перетаскивания, но не знает, что считать допустимым файлом, — а без этого приём файлов был бы обещанием, которое кит выполнить не может.

Без скрипта зона работает: внутри настоящий <input type="file">, и выбор через диалог доступен и мышью, и с клавиатуры.

Что должно сделать приложение

for (const type of ['dragenter', 'dragover']) {
  zone.addEventListener(type, (e) => {
    e.preventDefault();          // без этого браузер откроет файл сам
    zone.dataset.over = '';
  });
}
for (const type of ['dragleave', 'drop']) {
  zone.addEventListener(type, () => delete zone.dataset.over);
}

zone.addEventListener('drop', (e) => {
  e.preventDefault();
  input.files = e.dataTransfer.files; // тот же путь, что у диалога
  input.dispatchEvent(new Event('change', { bubbles: true }));
});

Перетащенное кладётся в тот же <input>. Отдельный список дал бы форме два источника файлов, и они разошлись бы на первой же отправке.

Композиции

В форме с подписью

Пример
Импорт списка получателей
Разметка
<div class="inst-field">
  <span class="inst-label">Импорт списка получателей</span>
  <label class="inst-file">
    <input type="file" multiple accept=".json,.csv">
    Перетащите файлы или выберите
    <span class="inst-file-hint">До 20 МБ, форматы .json и .csv</span>
  </label>
</div>

Подпись здесь <span class="inst-label">, а не <label for>: обёртка зоны сама является <label>, и второй ярлык на то же поле дал бы два конкурирующих имени.

Сценарии

С отчётом о загрузке

В контексте
Датасет
terrain.csv62%
Разметка
<div class="inst-field">
  <span class="inst-label">Датасет</span>
  <label class="inst-file">
    <input type="file" accept=".csv">
    Перетащите файл или выберите
    <span class="inst-file-hint">CSV, до 100 МБ</span>
  </label>
  <div class="inst-meter-row"><span>terrain.csv</span><span class="inst-meter-value">62%</span></div>
  <div class="inst-meter" role="progressbar" aria-label="Загрузка terrain.csv"
       aria-valuenow="62" aria-valuemin="0" aria-valuemax="100">
    <div class="inst-meter-fill" style="inline-size:62%"></div>
  </div>
</div>

Правила

Так label вокруг нативного input

Системный диалог, accept, участие в форме и клавиатура приходят от платформы. Вся зона становится целью нажатия.

Не так display: none на поле

Спрятанное так поле выпадает из порядка обхода, и зона перестаёт достигаться клавиатурой. Кит уводит его в клип 1×1.

Так Ограничения до выбора

Формат и размер — в inst-file-hint, рядом с зоной. Узнать о лимите из ошибки после загрузки стомегабайтного файла — плохая сделка.

Не так Загрузка только перетаскиванием

Она недоступна почти никому, кроме мыши. Нажатие и клавиатура обязаны открывать тот же диалог.

Доступность

Контрол Нативный input[type=file]. Системный диалог, фильтр по accept и участие в форме — от платформы
Клавиатура Tab — фокус на зоне, Enter и Space — открыть диалог. Поле в клипе, а не спрятано: спрятанное display: none выпало бы из обхода
Фокус Кольцо вокруг всей зоны, а не вокруг невидимого поля 1×1
Имя Текст-призыв внутри <label>. Подсказка про формат читается следом, потому что тоже внутри
Перетаскивание не единственный способ Мышью, клавиатурой и через системный диалог зона достижима одинаково. Загрузка только перетаскиванием недоступна почти никому, кроме мыши
Цвет не единственный носитель Зона узнаётся по пунктиру и значку, а не по цвету рамки
Цель нажатия Вся зона, высотой в несколько строк
Ошибка Сообщение о неверном формате — текстом рядом (сноска или баннер), а не только красной рамкой

API

ИмяЗначениеЧто делает
класс
inst-file<label>-зона: пунктир, значок, состояния
inst-file-hintОграничения формата и размера
токен
--space-724px
--pad-cardvar(--space-6)
--stroke1px
--radius-md7px
--surface-fieldlight-dark(var(--n-1), var(--n-14))
--border-controllight-dark(oklch(0 0 0 / 0.46), oklch(1 0 0 / 0.38))
--accent-borderlight-dark(var(--a-4), var(--a-3))
--accent-bglight-dark(var(--a-1), color-mix(in oklab, var(--a-4) 15%, transparent))
--focus-ringlight-dark(var(--a-4), var(--a-3))
--size-icon16px
--text-sm0.8125rem
--text-xs0.75rem
--space-36px

Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument