Перейти к содержимому
EN
Масштаб
Плотность
Тема
Акцент

Ввод

Поле файла

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

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

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

Внутри зоны нативный input[type=file]: системный диалог, фильтр по accept и участие в форме приходят от платформы. Поле лежит в клипе, а не спрятано — display: none выбросил бы его из обхода. Tab ставит фокус на зону, Enter и Space открывают диалог, а кольцо рисуется вокруг всей зоны, а не вокруг невидимого поля 1×1.

Имя даёт текст-призыв внутри <label>; подсказка про формат читается следом, потому что тоже внутри.

Перетаскивание не единственный способ, и это главное: мышью, клавиатурой и через системный диалог зона достижима одинаково. Загрузка только перетаскиванием недоступна почти никому, кроме мыши. Сама зона узнаётся по пунктиру и значку, а не по цвету рамки, а сообщение о неверном формате даётся текстом рядом (сноской или баннером).

Состояния

Пример
Разметка
<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

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

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

Что делает instrument.js

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

Без скрипта зона работает: внутри настоящий <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>

API

Классы
ИмяЧто делает
класс
inst-file<label>-зона: пунктир, значок, состояния
inst-file-hintОграничения формата и размера
Токены
ИмяЗначениеОткуда
токен
--space-724pxодно на все ячейки
--pad-cardvar(--space-6)база +14: плотность · масштаб
--stroke1pxодно на все ячейки
--radius-md8pxбаза +4: масштаб
--surface-fieldvar(--surface-raised)одно на все ячейки
--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-icon18pxбаза +4: масштаб
--text-sm0.875remбаза +4: масштаб
--text-xs0.78125remбаза +4: масштаб
--space-36pxодно на все ячейки

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