Ввод
Поле файла
Выбор файла и зона, в которую его кладут, — один компонент, а не два. Пунктирная рамка означает «сюда можно положить»; нативная кнопка выбора спрятана и заменена всей площадью зоны.
Разметка
<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>, и второй ярлык на то же поле дал бы два
конкурирующих имени.
Сценарии
С отчётом о загрузке
Разметка
<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-7 | 24px | |
--pad-card | var(--space-6) | |
--stroke | 1px | |
--radius-md | 7px | |
--surface-field | light-dark(var(--n-1), var(--n-14)) | |
--border-control | light-dark(oklch(0 0 0 / 0.46), oklch(1 0 0 / 0.38)) | |
--accent-border | light-dark(var(--a-4), var(--a-3)) | |
--accent-bg | light-dark(var(--a-1), color-mix(in oklab, var(--a-4) 15%, transparent)) | |
--focus-ring | light-dark(var(--a-4), var(--a-3)) | |
--size-icon | 16px | |
--text-sm | 0.8125rem | |
--text-xs | 0.75rem | |
--space-3 | 6px | |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument