Ввод
Селект
Выбор одного значения из списка. Нативный <select> — со всей клавиатурой,
поиском по первым буквам и системным выпадающим списком, который на телефоне
станет колесом.
Разметка
<span class="inst-select-wrap">
<select class="inst-select" aria-label="Модель">
<option>opus</option>
<option>sonnet</option>
<option>haiku</option>
</select>
</span>
Контракт
Обёртка обязательна — в ней живёт шеврон: псевдоэлемент самого <select>
браузер не отдаёт.
| Что | Обязательно | Почему |
|---|---|---|
inst-select-wrap на обёртке |
да | Шеврон рисуется псевдоэлементом обёртки. Без неё поле останется без стрелки и будет неотличимо от текстового |
inst-select на поле |
да | Врез, рамка, высота и состояния приходят оттуда |
<label for> или aria-label |
да | Первый <option> подписью не является |
Клавиатура целиком нативная: стрелки, Home и End, поиск по первым буквам,
Enter. Подпись даёт <label for> или aria-label — первый <option>
подписью не является.
Шеврон нарисован маской и получает pointer-events: none, чтобы клик по
стрелке открывал список, а не проваливался мимо; в forced-colors маска
теряет цвет фона и потому переводится на CanvasText явно.
Нативный список остаётся нативным намеренно: на телефоне он превращается в системное колесо, и самодельный выпадающий это теряет.
Размеры
Разметка
<span class="inst-select-wrap">
<select class="inst-select inst-select--sm" aria-label="sm"><option>sm</option></select>
</span>
<span class="inst-select-wrap">
<select class="inst-select" aria-label="md"><option>md</option></select>
</span>
<span class="inst-select-wrap">
<select class="inst-select inst-select--lg" aria-label="lg"><option>lg</option></select>
</span>
Симметричны кнопочным и полевым: селект, поле и кнопка одного размера стоят в одной строке формы и разойтись не могут.
Состояния
Разметка
<span class="inst-select-wrap">
<select class="inst-select" aria-label="Обычный"><option>Обычный</option></select>
</span>
<span class="inst-select-wrap">
<select class="inst-select" aria-label="Недоступен" disabled><option>Недоступен</option></select>
</span>
<span class="inst-select-wrap">
<select class="inst-select" aria-label="Ошибка" aria-invalid="true"><option>Ошибка</option></select>
</span>
Врез, рамка, фокус, ошибка, disabled и readonly — те же, что у
текстового поля: все три контрола объявлены одним правилом,
поэтому разойтись не могут.
JS
Нативный <select> не требует ни строки: клавиатура, набор по первым буквам,
список на весь экран телефона — всё от платформы. Скрипт нужен только там, где
нативного мало: поиск по списку, две строки в пункте, иконка ряда.
Когда стоит уходить от нативного
| Больше двух-трёх десятков вариантов | Нужен поиск, а в <select> его нет |
| В пункте не только текст | Нативный список рисует одну строку без разметки |
| Выбор нескольких | <select multiple> выглядит как окно списка и на телефоне неудобен |
Во всех остальных случаях нативный выигрывает, и заменять его — работа без выигрыша.
Свой список
Поверхность и клавиатуру даёт библиотека. Приложению остаются данные: открыть, отфильтровать, записать выбор в скрытое поле.
Разметка
<div class="inst-field" style="max-inline-size:16rem">
<label class="inst-label" for="pick">Прогон</label>
<span class="inst-select-wrap">
<button class="inst-select" type="button" id="pick"
popovertarget="pick-list" aria-haspopup="listbox">worldgen-01</button>
</span>
<div class="inst-popover inst-popover--fill inst-popover--anchored" id="pick-list" popover>
<input class="inst-input inst-input--sm" type="search" aria-label="Поиск по прогонам" placeholder="Поиск">
<div role="listbox" aria-label="Прогоны">
<div class="inst-menu-item" role="option" aria-selected="true" tabindex="0">worldgen-01</div>
<div class="inst-menu-item" role="option" aria-selected="false" tabindex="-1">biomes-04</div>
<div class="inst-menu-item" role="option" aria-selected="false" tabindex="-1">rivers-12</div>
</div>
</div>
</div>
Стрелки, Home, End и перенос выбора внутри списка выполняет
instrument.js — это role="listbox". Открытие и
закрытие берёт Popover API.
События
list.addEventListener('inst:select', (e) => {
trigger.textContent = e.detail.value;
hidden.value = e.detail.value; // значение уходит с формой
list.hidePopover();
});
Скрытое поле обязательно: свой список — это разметка, а форма отправляет поля. Без него выбор виден человеку и не виден серверу.
API
| Имя | Что делает |
|---|---|
| класс | |
inst-select-wrap | Обёртка. Обязательна — в ней шеврон |
inst-select | Сам <select> |
| модификатор | |
inst-select--sm | Размер |
inst-select--lg | Размер |
| Имя | Значение | Откуда |
|---|---|---|
| токен | ||
--surface-field | var(--surface-raised) | одно на все ячейки |
--control-h-sm/md/lg | — | одно на все ячейки |
--control-pad-sm | calc(var(--control-h-sm) * var(--control-ratio-sm)) | одно на все ячейки |
--size-chevron | 10px | база +4: масштаб |
--space-7 | 24px | одно на все ячейки |
--radius-sm | 6px | база +4: масштаб |
--border-control | light-dark(oklch(0 0 0 / 0.46), oklch(1 0 0 / 0.38)) | одно на все ячейки |
--text-muted | light-dark(var(--n-8), var(--n-6)) | одно на все ячейки |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument