Ввод
Поле файла
Выбор файла и зона, в которую его кладут, — один компонент, а не два. Пунктирная рамка означает «сюда можно положить»; нативная кнопка выбора спрятана и заменена всей площадью зоны.
Разметка
<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>, и второй ярлык на то же поле дал бы два
конкурирующих имени.
Сценарии
С отчётом о загрузке
Разметка
<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-7 | 24px | одно на все ячейки |
--pad-card | var(--space-6) | база +14: плотность · масштаб |
--stroke | 1px | одно на все ячейки |
--radius-md | 8px | база +4: масштаб |
--surface-field | var(--surface-raised) | одно на все ячейки |
--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 | 18px | база +4: масштаб |
--text-sm | 0.875rem | база +4: масштаб |
--text-xs | 0.78125rem | база +4: масштаб |
--space-3 | 6px | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument