Формы
Поля, метки, подсказки, флажки, переключатели, сегментный выбор и ползунок. Ни один элемент не подменяется другим: класс ставится на сам <input> или <select>.
Поле и его обвязка
| Класс | Назначение |
|---|---|
.gr-field | Обёртка «метка + поле + подсказка» одной колонкой |
.gr-label | Метка поля |
.gr-hint | Пояснение под полем |
.gr-error | Сообщение об ошибке |
.gr-success | Подпись о принятом значении: пара к .gr-error |
.gr-required | Звёздочка обязательности вручную — для групп; у поля с required она появляется сама |
.gr-input | Однострочное поле любого типа — включая date, time и color: ни один селектор семьи не ограничен типом |
.gr-input-no-spin | Числовое поле без системных стрелок шага: в счётчике количества шагают кнопки рядом. Клавиатурный шаг ↑ / ↓ остаётся |
.gr-textarea | Многострочное; растёт по содержимому (field-sizing: content: Chrome 123, Firefox 152, Safari 26.2) от двух строк до max-block-size, если его задать; тянется ручкой только по вертикали. Где свойства нет — высота прежняя |
.gr-select | Выпадающий список; стрелка своя, отбита от края поля |
<div class="gr-field">
<label class="gr-label" for="mail">Почта</label>
<input class="gr-input" id="mail" type="email" aria-describedby="mail-hint">
<span class="gr-hint" id="mail-hint">Пришлём подтверждение.</span>
</div>
Стрелка у списка своя — и это единственное исключение.
Положение системной стрелки задаёт браузер: на
padding-inline-end она не реагирует и прижимается вплотную
к краю поля, а в разных браузерах ещё и выглядит по-разному. Поэтому
у .gr-select стоит appearance: none, а стрелка
нарисована двумя линейными градиентами и отбита от края на
--gr-control-padding-x. Цвет берётся из
currentcolor, поэтому она следует и теме, и состоянию
:disabled — SVG с зашитым цветом не следовал бы ни тому,
ни другому. Сам элемент остаётся <select>: календарь
у <input type="date"> и палитра у
type="color" по-прежнему системные.
.gr-label не заменяет <label for>, а
.gr-hint — aria-describedby. Класс красит,
связь создаёт разметка: без for клик по метке не переводит
фокус в поле, а без aria-describedby подсказку не услышит
тот, кто не видит её на экране.
Состояния
| Состояние | Селектор | Что меняется |
|---|---|---|
| Фокус с клавиатуры | :focus-visible | Акцентная граница и контур --gr-focus-width |
| Ошибка после ввода | :user-invalid | Красная граница |
| Ошибка от сервера | [aria-invalid="true"] | То же самое |
| Ошибка на обёртке | .gr-field[data-gr-state="error"] | Красная граница у всех вложенных .gr-input / .gr-select / .gr-textarea / .gr-file; aria-invalid на контроле — для программы чтения с экрана |
| Принято сервером | [aria-invalid="false"] | Граница цвета успеха |
| Обязательное поле | .gr-field:has([required]) | Звёздочка после текста метки |
| Недоступно | :disabled | Утопленный фон, приглушённый текст, курсор not-allowed |
| Только чтение | :read-only | Фон поверхности; поле остаётся выделяемым и копируемым |
Почему :user-invalid, а не :invalid.
Пустое обязательное поле невалидно с первой миллисекунды загрузки страницы.
По :invalid форма покраснела бы целиком до того, как человек
успел что-то напечатать. :user-invalid срабатывает только
после того, как поле тронули и покинули.
Почему успех не парен ошибке псевдоклассом.
:user-valid браузер ставит любому непустому полю без
ограничений: поле «Комментарий» без pattern и
required валидно с первой введённой буквы. Форма зеленела бы
сама собой по мере заполнения и превращалась в светофор. Поэтому успех
показывается только по [aria-invalid="false"] — а его ставит
та же серверная проверка, что ставит и "true".
<div class="gr-field">
<label class="gr-label" for="promo">Промокод</label>
<input class="gr-input" id="promo" type="text" value="OSEN30"
aria-invalid="false" aria-describedby="promo-ok">
<span class="gr-success" id="promo-ok">Скидка 30 % применена к заказу.</span>
</div>
<div class="gr-field">
<label class="gr-label" for="phone">Телефон</label>
<input class="gr-input" id="phone" type="tel" required aria-describedby="phone-hint">
<span class="gr-hint" id="phone-hint">Обязательное поле — по нему свяжется курьер.</span>
</div>
Звёздочка декоративна. Обязательность объявляет атрибут
required: его читает и платформа, и скринридер, и проверка
формы. Звёздочка лишь показывает её глазами — и появляется от того же
атрибута, без класса в разметке. Цвет и знак не заменяют слова: в примере
выше обязательность продублирована подсказкой, как того же требует правило
«статус читается словом» от сообщений.
Группе, где обязательность лежит не на одном контроле — набор флажков
в <fieldset>, из которого нужно выбрать хотя бы один, —
звёздочка ставится вручную: <legend class="gr-label
gr-required">. Атрибута, выражающего такое условие, в платформе нет.
Размеры
.gr-input, .gr-textarea и .gr-select —
одна семья элементов управления: общая высота, общий кегль, общие состояния.
Поэтому и размер у них задаётся одной парой классов, на любом из трёх.
| Класс | Высота |
|---|---|
.gr-input-sm | --gr-control-height-sm |
| — | --gr-control-height |
.gr-input-lg | --gr-control-height-lg |
Группа с аддонами
.gr-input-group склеивает поле с подписью или кнопкой:
скругления остаются на краях, соседние границы наезжают друг на друга
на свою толщину, а сфокусированный элемент поднимается над соседями,
чтобы контур фокуса не оказался наполовину закрыт.
<div class="gr-input-group">
<span class="gr-input-addon">₽</span>
<input class="gr-input" type="number" aria-label="Сумма">
</div>
Края считаются по видимым полям. Скрытые потомки —
<input type="hidden">, узел с атрибутом
hidden, <template>,
<script>, <datalist> — на вид
группы не влияют, где бы они ни стояли: полное значение составного поля
в скрытом поле, признак формы первым ребёнком, панель подсказки, которую
живой поиск дописывает внутрь группы. Это допустимая разметка, а не
обход.
<div class="gr-input-group">
<select class="gr-select" aria-label="Код страны"><option>+7</option></select>
<input class="gr-input" type="tel" aria-label="Номер">
<input type="hidden" name="telephone"> <!-- последний ребёнок, но не край -->
</div>
Как это устроено и где деградирует. Скругления
и сдвиг на ширину рамки задаются по :nth-child(1 of S)
и :nth-last-child(1 of S), где S — «не
скрытый». Прежние правила по :first-child /
:last-child при этом остаются вторым ярусом: форма
of S появилась в Chrome 111 и Firefox 113 — позже
каскадных слоёв, — и браузер между двумя порогами отбрасывает новое
правило, а группу рисует по-старому: со скрытым полем в конце край
выходит прямым, без него всё как всегда. Это мягкая деградация,
строка в таблице совместимости.
Выбор даты и времени
Ответ по умолчанию — нативное <input type="date">,
и это не пробел. Локализация, разбор введённого, клавиатура, работа
со скринридером и мобильный календарь там уже есть — и сделаны
платформой лучше, чем их сделал бы виджет на разметке. Библиотеке
остаётся оформить поле, и она это делает без единого нового класса:
.gr-input работает на дате ровно так же, как на тексте.
Второй путь — виджет data-gr-datetime из второго бандла
слоя — существует и назван ниже, в подразделе
«С подключённым griffinjs-fields.js»,
вместе со своей платой; заменой нативного поля он не является:
на телефоне он и сам уступает системному календарю.
Ни один селектор семьи не ограничен типом поля, поэтому дате и времени
достаются и рамка с фоном, и фокус, и :user-invalid,
и [aria-invalid], и :disabled, и оба размера,
и группа с аддонами.
<div class="gr-field">
<label class="gr-label" for="from">Дата заезда</label>
<input class="gr-input" id="from" type="date" required
min="2026-01-01" aria-describedby="from-hint">
<span class="gr-hint" id="from-hint">Не раньше начала года.</span>
</div>
Плата
Нативное поле — не «замена похуже», но и не бесплатное решение. Что именно вы отдаёте, лучше знать до того, как макет разойдётся с результатом.
| Чего нельзя | Подробность |
|---|---|
| Стилизовать календарь | Выпадающая панель рисуется вне страницы средствами браузера. CSS-хуков к ней нет ни в одном движке — ни к сетке дней, ни к заголовку, ни к кнопкам месяцев |
| Задать формат и порядок полей | Их определяет локаль браузера, а не lang документа. Читатель с английской локалью увидит mm/dd/yyyy на русской странице, и это правильно: формат — его настройка, а не ваша |
| Покрасить заготовку пустого поля | mm/dd/yyyy в пустом поле — не ::placeholder, а часть редактора значения. Приглушённый цвет .gr-input::placeholder до неё не достаёт |
| Управлять значком календаря | В Blink это ::-webkit-calendar-picker-indicator, в Firefox — свой орган управления, в Safari его нет вовсе. Косметика на нём покупает один движок из трёх |
| Рассчитывать на зеркалирование в RTL | Blink порядок полей не зеркалит, Firefox и WebKit зеркалят |
Высота выровнена, но не везде до нуля. Внутренний
редактор значения в Blink и WebKit несёт свои вертикальные отступы,
и поле даты оказывалось выше соседнего текстового. Два правила
в _form.scss снимают эти отступы, и на осях оформления
по умолчанию, airy и strict расхождение
в трёх движках равно нулю. Под предустановкой compact
остаток есть: 2 px в WebKit и 0,75 px у type="time"
в Blink — вдвое меньше, чем было, но не ноль. Firefox совпадает
везде и точно. Замер — npm run test:browser.
Диапазон «от — до»
Пара дат — это два поля и одно правило между ними. База без скрипта —
два нативных поля: границу задаёт разметка через min
и max, платформа проверит введённое и сама пометит поле
недействительным. С подключённым бандлом полей поля связываются одним
атрибутом — data-gr-datetime="to: #b" на первом: значение
«с» становится min у «по», значение «по» — max
у «с», и панель одного показывает отрезок до другого. Подробности —
в подразделе «Диапазон „с — по“» ниже.
<div class="gr-flex gr-gap-sm">
<div class="gr-field gr-flex-1 gr-min-w-0">
<label class="gr-label" for="a">С</label>
<input class="gr-input" id="a" type="date" max="2026-12-31">
</div>
<div class="gr-field gr-flex-1 gr-min-w-0">
<label class="gr-label" for="b">По</label>
<input class="gr-input" id="b" type="date" max="2026-12-31">
</div>
</div>
.gr-min-w-0 на колонках обязателен, а не для красоты:
у флекс-элемента минимальная ширина по умолчанию равна ширине
содержимого, а собственная ширина поля даты в Blink — около 154 px.
Без него пара полей не сожмётся ниже своей суммы и уведёт страницу
вбок на узком экране.
Числовой диапазон — другая задача и решается иначе: у ползунка пара
«от — до» держит порядок значений сама, потому что там между ручками
есть физическая связь. Это подраздел
«С подключённым griffinjs.js»
ниже. У двух дат такой связи нет, и порядок им не навязывается
и со скриптом: «по» раньше «с» — ошибка ввода, которую показывает
проверка границ, а не молчаливая правка второго поля. Диапазон дат
одним контролом с двумя концами не делает и виджет второго бандла —
это приложение, а не поле; связанная пара нативных полей — поле.
Флажок, радиокнопка, переключатель
Элемент не подменяется. Класс ставится на сам
<input>, а не на спрятанный input рядом с оформленным
<span>. Родная разметка сохраняет участие в форме,
клавиатуру и объявление состояния скринридером — всё то, что при подмене
приходится восстанавливать вручную и обычно восстанавливают наполовину.
Цвет отметки у флажка и радиокнопки задаёт одно свойство
accent-color: рисует их по-прежнему операционная система.
| Класс | Элемент |
|---|---|
.gr-checkbox | <input type="checkbox"> |
.gr-radio | <input type="radio"> |
.gr-switch | <input type="checkbox"> в виде тумблера |
.gr-choice | <label>-обёртка: подпись в строку с элементом и кликабельна целиком |
.gr-choice-start | Контрол по первой строке подписи, а не по центру блока: согласие в две-три строки |
.gr-choice-row | Строка настроек во всю ширину: подпись слева, тумблер у правого края, кликается вся строка |
<label class="gr-choice gr-choice-start">
<input class="gr-checkbox" type="checkbox" required>
<span>Я прочитал условия использования и политику обработки персональных данных и согласен с ними</span>
</label>
<label class="gr-choice gr-choice-row">Подписка на рассылку <input class="gr-switch" type="checkbox"></label>
Почему это модификаторы, а не рецепт из утилит.
.gr-choice задаёт align-items: center
и display: inline-flex в слое griffincss.ui,
а утилиты флекса — .gr-flex-items-start,
.gr-flex — лежат в слое griffincss.core
и проигрывают ему при любой специфичности: композицией контрол
по первой строке не поставить. Строка во всю ширину собиралась
через .gr-w-full из пакета утилит — а пакет
компонентов обязан собираться и без него. Два модификатора
по две строки стоят дешевле, чем эта оговорка в каждом проекте.
Тумблера среди типов <input> нет, поэтому его дорожка
и бегунок нарисованы фоном: background-image с кругом
и background-origin: content-box, чтобы отступ от края
задавался обычным padding. Псевдоэлементы на
<input> поддерживаются не везде, а фон работает всюду.
Сам элемент остаётся <input type="checkbox">.
Переезд бегунка снимается при prefers-reduced-motion: reduce;
цвет продолжает меняться плавно — это не движение.
Сегментный переключатель
Ряд взаимоисключающих вариантов из обычной радиогруппы. Стрелки
← → переключают выбор — это даёт платформа,
скрипта не нужно. Оформляется <fieldset>, поэтому
у группы есть имя: <legend> или aria-label.
<fieldset class="gr-segmented" aria-label="Период">
<label><input type="radio" name="period" value="d" checked><span>День</span></label>
<label><input type="radio" name="period" value="w"><span>Неделя</span></label>
</fieldset>
Имя группы можно дать и <legend> — штатным способом
<fieldset>, в том числе скрытым с экрана: края
и внутренние рамки считаются по ярлыкам (label:first-of-type,
label:last-of-type), а не по любым потомкам, поэтому ни
<legend> первым, ни скрытое поле или
<script> последним сегменты не ломают.
<legend><fieldset class="gr-segmented">
<legend class="gr-sr-only">Тема оформления</legend>
<label><input type="radio" name="theme" value="auto" checked><span>Как в системе</span></label>
<label><input type="radio" name="theme" value="light"><span>Светлая</span></label>
<label><input type="radio" name="theme" value="dark"><span>Тёмная</span></label>
</fieldset>
Догфудинг. Переключатель темы на боковой панели этой
документации собран ровно на этом компоненте. Раньше он был написан
вручную в docs/style.css — потребность обнаружилась
в фазе 6a, раньше, чем компонент запланировали. Ровно то, ради чего
документация и собирается на самой библиотеке.
Выбор цвета и размера
Карточке товара нужны радио, которые выглядят не как радио: кружок цвета
и плашка размера. Разметка — та же строка выбора, что и у флажка:
<label class="gr-choice">, внутри
<input class="gr-radio"> и образец следом —
.gr-swatch для цвета, .gr-swatch-tag для плашки.
Форма, клавиатура и объявление состояния остаются платформенными:
стрелки переключают выбор внутри группы, Tab входит в неё
один раз.
Цвет образца — переменная на элементе,
style="--gr-swatch: …": цвет товара — данные страницы,
а не оформление, и в библиотеке от него не остаётся ни одного литерала.
Цвет — не подпись: у радио обязателен aria-label
(и title на строке даст ту же подсказку под курсором).
Группе нужно имя — role="radiogroup"
с aria-labelledby или <fieldset>.
<div class="gr-flex gr-flex-items-center gr-gap-sm" role="radiogroup" aria-labelledby="color-label">
<span class="gr-text-sm gr-text-ink-secondary" id="color-label">Цвет</span>
<label class="gr-choice" title="Чёрный">
<input class="gr-radio" type="radio" name="color" value="black" checked aria-label="Чёрный">
<span class="gr-swatch" style="--gr-swatch: #1f1f1f"></span>
</label>
<label class="gr-choice" title="Песочный">
<input class="gr-radio" type="radio" name="color" value="sand" aria-label="Песочный">
<span class="gr-swatch" style="--gr-swatch: #d9c3a0"></span>
</label>
</div>
<div class="gr-flex gr-flex-items-center gr-flex-wrap gr-gap-sm" role="radiogroup" aria-labelledby="size-label">
<span class="gr-text-sm gr-text-ink-secondary" id="size-label">Размер</span>
<label class="gr-choice"><input class="gr-radio" type="radio" name="size" value="s"><span class="gr-swatch-tag">S</span></label>
<label class="gr-choice"><input class="gr-radio" type="radio" name="size" value="m" checked><span class="gr-swatch-tag">M</span></label>
<label class="gr-choice"><input class="gr-radio" type="radio" name="size" value="xl" disabled><span class="gr-swatch-tag">XL</span></label>
</div>
Выбранный кружок получает кольцо акцента с просветом цвета подложки —
на нём одинаково читаются и белый образец, и образец цвета акцента;
выбранная плашка заливается акцентом. Сама радио остаётся в потоке,
размером в пиксель и прозрачной, и прячется правилом
с :has(): там, где его нет (Firefox до 121), правило
не применяется вовсе, и радио остаётся видимой рядом с образцом —
группа работает, только выглядит проще. display: none
снял бы и клавиатуру, и эту деградацию.
Ползунок
.gr-range — оформление обычного
<input type="range">: фильтр цены в каталоге, громкость,
доля. Элемент не подменяется и здесь: клавиатура, шаг, min,
max и участие в форме остаются платформенными.
| Ручка | По умолчанию | Что задаёт |
|---|---|---|
--gr-range-track | 0.375rem | Толщину дорожки |
--gr-range-thumb | 1.125rem | Размер бегунка и высоту элемента |
<label class="gr-label" for="volume">Громкость</label>
<input class="gr-range" id="volume" type="range" min="0" max="100" value="65">
Шесть вендорных псевдоэлементов, каждый — своим правилом.
Неизвестный селектор в группе делает невалидной всю группу: WebKit,
встретив ::-moz-range-thumb рядом со своим, выбросит правило
целиком, и бегунок останется системным в обоих движках. При этом
accent-color не снимается — он единственное, что делает
ползунок частью библиотеки там, где appearance: none ещё
не применён.
Заполненную часть дорожки Firefox рисует сам
(::-moz-range-progress) и без скрипта. У WebKit
псевдоэлемента для неё нет вовсе: там след рисует градиент, а градиенту
нужна доля пройденного пути — число, которого в CSS взять неоткуда.
Его пишет виджет range слоя
GriffinJS одним свойством
--gr-progress; как это выглядит — в
подразделе о слое внизу страницы.
Чисто-CSS-приём проверен и отвергнут. Тень бегунка,
обрезанная дорожкой с overflow: hidden, красит след без
единой строки скрипта — но та же обрезка съедает и сам бегунок: круглая
ручка крупнее дорожки просто исчезает. След без ручки не стоит ручки
без следа, поэтому закраска у WebKit опциональна, а вид ползунка — нет.
Примеры использования
Целые формы уже разобраны в соседних главах: экран входа — на Sizing, ритм стопки полей — на Spacing. Здесь — сюжеты про сами контролы: ошибка, которую видно и слышно, поисковая строка с аддоном, пароль с кнопкой «показать», страница настроек на тогглах, фильтр цены и счётчик количества.
Ошибка от сервера: видно и слышно
Сервер отверг значение — поле получает aria-invalid="true"
(граница краснеет сама), а текст ошибки встаёт под ним на месте
подсказки: .gr-hint .gr-text-danger. Связь между ними —
aria-describedby: без него скринридер прочитает «поле
неверно», но не скажет почему. Пока ошибки нет, в том же месте живёт
обычный серый .gr-hint — вёрстка не прыгает.
<div class="gr-field">
<label class="gr-label" for="promo">Промокод</label>
<input class="gr-input" id="promo" type="text" value="LETO25"
aria-invalid="true" aria-describedby="promo-err">
<p class="gr-hint gr-text-danger" id="promo-err">Промокод истёк 1 августа.</p>
</div>
aria-invalid, причина — в связанном текстеПромокод истёк 1 августа.
Когда ошибки красит скрипт после ответа сервера, ему проще ставить
состояние на обёртку: .gr-field[data-gr-state="error"]
красит все вложенные контролы, и список «поле → сообщение» из ответа
ложится в одну строку на поле. aria-invalid при этом
остаётся на контроле — для программы чтения с экрана, а не для рамки.
Виджет data-gr-validate
второго бандла ставит оба сам.
// ответ сервера: { promo: 'Промокод истёк 1 августа.' }
for (const [name, message] of Object.entries(errors)) {
const control = form.elements[name];
control.closest('.gr-field').dataset.grState = 'error'; // рамка
control.setAttribute('aria-invalid', 'true'); // читалка
control.nextElementSibling.textContent = message; // .gr-error рядом
}
Поиск с кнопкой и подсказкой символа
Строка поиска или поле суммы: аддоны несут статичный контекст
(₽, «в месяц», лупа-символ), кнопка завершает группу. Всё это —
.gr-input-group: скругления по внешним углам, общая
граница, фокус поднимается над соседями. Мелкий шрифт аддона
и placeholder делают иерархию сами — дополнительных классов не нужно.
<div class="gr-input-group">
<span class="gr-input-addon">⌕</span>
<input class="gr-input" type="search" placeholder="Поиск по заказам" aria-label="Поиск по заказам">
<button class="gr-btn gr-btn-primary">Найти</button>
</div>
Пароль: показать и скрыть
Поле пароля и кнопка рядом — та же .gr-input-group, что
у поиска. Кнопка — переключатель: aria-pressed держит
состояние, подпись говорит, что случится по нажатию. Переключают три
строки на странице: type поля, aria-pressed
и текст кнопки. Виджета здесь нет и не нужно — ради трёх строк заводить
data-gr-* значило бы платить за них каждому, кто подключил
слой. Значение при смене type остаётся на месте во всех
трёх движках; autocomplete="current-password" оставляет
менеджеру паролей его подсказку.
<div class="gr-input-group">
<input class="gr-input" id="pass" type="password" autocomplete="current-password">
<button class="gr-btn" type="button" aria-pressed="false"
onclick="const f = this.previousElementSibling, on = f.type === 'password';
f.type = on ? 'text' : 'password';
this.setAttribute('aria-pressed', on);
this.textContent = on ? 'Скрыть' : 'Показать'">Показать</button>
</div>
type поля, aria-pressed и подпись меняются вместеСтраница настроек на тогглах
Список настроек — строки «подпись слева, тумблер справа». Каждая
строка — .gr-choice с модификатором
.gr-choice-row: блочный флекс во всю ширину, подпись
и .gr-switch разведены по краям;
.gr-py-3 .gr-border-b дают ритм и разделители.
Кликается вся строка — это даёт сам label. До 0.26.0
то же собиралось из .gr-w-full .gr-flex-between, и рецепт
по-прежнему работает — но требует пакета утилит.
<div style="max-width: 26rem">
<label class="gr-choice gr-choice-row gr-py-3 gr-border-b">
Уведомления о заказах <input class="gr-switch" type="checkbox" checked>
</label>
<label class="gr-choice gr-choice-row gr-py-3 gr-border-b">
Письма о новинках <input class="gr-switch" type="checkbox">
</label>
<label class="gr-choice gr-choice-row gr-py-3">
Тёмная тема <input class="gr-switch" type="checkbox" checked>
</label>
</div>
.gr-choice-row, кликается целикомФильтр цены в каталоге
Ползунок задаёт верхнюю границу, поле рядом показывает и принимает то же
число: одно значение, два способа его ввести. Ни один из них не главный —
и связывает их та пара строк скрипта, которую пишет потребитель.
Библиотеке здесь принадлежит только вид: .gr-range,
.gr-input-group с аддоном валюты и .gr-field
как обёртка. Со слоем griffinjs.js эти строки не пишутся:
связку берёт на себя параметр fields виджета —
ниже.
<div class="gr-field">
<label class="gr-label" for="price">Цена, до</label>
<input class="gr-range" id="price" type="range" min="0" max="20000" step="500" value="8000">
<div class="gr-input-group">
<input class="gr-input gr-input-sm" id="price-num" type="number" value="8000" aria-label="Цена, до">
<span class="gr-input-addon gr-input-sm">₽</span>
</div>
</div>
С подключённым griffinjs.js
Всё выше работает без скрипта. Этот подраздел — о том, что добавляет
ползунку опциональный слой GriffinJS:
разметка и классы те же, скрипт читает data-gr-range
и пишет на каждую ручку одно свойство — --gr-progress,
долю пройденного пути от min до max. Рисует
по ней CSS модуля. Набор для этого — griffinjs-core.js
плюс griffinjs-range.js, 3,5 КБ gzip.
Закраска пройденного пути
Один атрибут на самом ползунке. Без скрипта у WebKit след не появляется, а сам ползунок работает как работал; в Firefox след есть в обоих случаях — его рисует платформа.
<input class="gr-range" type="range" min="0" max="100" value="65" data-gr-range>
Пара «от — до»
.gr-range-pair — два обычных ползунка в одной ячейке сетки.
Дорожка и закрашенный отрезок принадлежат самой обёртке, а ползунки
отдают ей всё, кроме ручек: наложением отрезок не собрать — дорожка
полупрозрачна намеренно, оттенок currentcolor ложится
на любую подложку, и верхняя дорожка не закрыла бы акцент нижней,
а лишь притенила бы его.
Скрипту остаётся порядок значений: ручка, которую тянут за чужую границу, толкает вторую перед собой. Пересечься им нельзя — отрезок между ними вывернулся бы наизнанку. Без скрипта пара выглядит серой дорожкой с двумя ручками, и обе работают — мышью и с клавиатуры.
<div class="gr-range-pair" data-gr-range>
<input class="gr-range" type="range" min="0" max="20000" step="500" value="4000" aria-label="Цена от">
<input class="gr-range" type="range" min="0" max="20000" step="500" value="12000" aria-label="Цена до">
</div>
Подпись под парой ведёт разметка страницы, а не библиотека: числа рядом
с фильтром у каждого каталога свои — валюта, разряды, «плюс» на верхней
границе. Слой сообщает о каждом движении событием
griffin:change на обёртке; в нём уже наведён порядок, поэтому
слушать его, а не input самих ползунков, — единственный
надёжный способ показать числа: свой обработчик на ползунке отработает
раньше толчка второй ручки и покажет значения до него.
pair.addEventListener('griffin:change', (e) => {
out.textContent = `${e.detail.from} — ${e.detail.to} ₽`;
});
Пара и числовые поля
У фасета цены ползунок и числа обязаны жить вместе: потянул ручку —
число обновилось; вписал число — ручка уехала; форма отправляет одно
значение, а не два. Параметр fields связывает пару с полями
одним атрибутом — селекторов столько же, сколько ползунков, через запятую.
Логику порядка при этом не переписывает никто: она та же, что у ручек.
Форму отправляют поля. name стоит
у <input type="number">, у ползунков его нет — иначе
в адрес уехали бы четыре параметра. Без скрипта остаются два рабочих
числовых поля; со скриптом ползунок ложится поверх. Правда — в полях:
страница вернулась с параметрами в них, и на монтаже ручки встают
по полям, а пустое поле ставит свою ручку на край. Само поле от монтажа
не заполняется — «фильтр не задан» остаётся пустым, пока ручку не тронут.
Ввод в поле двигает ручку сразу, на input, с зажимом
по min/max; порядок наводится
на change — «1» по пути к «1000» иначе толкнул бы вторую
ручку. Событие griffin:change приходит на обёртку и при вводе
в поле, с тем же detail.
<div class="gr-range-pair" data-gr-range="fields: #price-min, #price-max">
<input class="gr-range" type="range" min="0" max="20000" step="500" value="0" aria-label="Цена от">
<input class="gr-range" type="range" min="0" max="20000" step="500" value="20000" aria-label="Цена до">
</div>
<div class="gr-flex gr-gap-sm">
<input class="gr-input gr-input-sm gr-flex-1" id="price-min" type="number" name="price_min" min="0" max="20000" step="500" aria-label="Цена от">
<input class="gr-input gr-input-sm gr-flex-1" id="price-max" type="number" name="price_max" min="0" max="20000" step="500" aria-label="Цена до">
</div>
Параметр data-gr-range | По умолчанию | Что делает |
|---|---|---|
fields | — | Селекторы числовых полей через запятую, по одному на ползунок: ручка пишет в поле, поле двигает ручку. Поле не найдено — виджет не поднимается и называет селектор в консоли |
Счётчик количества в корзине
«Минус — число — плюс» собирается из того же .gr-input-group,
что и поисковая строка: кнопки по краям, поле посередине,
.gr-text-center ставит число в середину.
Поле — настоящий <input type="number"> с
min, max и step: количество уезжает
вместе с формой, а на телефоне inputmode="numeric" открывает
цифровую клавиатуру. Кнопкам нужен aria-label — «плюс»
и «минус» знаками скринридер прочитает как пунктуацию.
Прибавляют и убавляют родные методы поля —
stepUp() и stepDown(): они уважают
min, max и step, поэтому ниже
единицы счётчик не уйдёт и выше сотни не поднимется. Библиотеке для этого
добавлять нечего — кнопке остаётся позвать метод у соседа. Счётчики
в демонстрации рабочие, нажмите.
Системные стрелки у поля при этом снимает
.gr-input-no-spin: рядом с парой кнопок они второй орган
управления тем же числом — вдвое мельче «плюса» и вплотную к нему.
Класс, а не автоматика по соседству с кнопкой: разметка нигде
не говорит, что кнопка рядом шаговая, и группа «число + Применить»
осталась бы без единственного способа шагнуть мышью. Клавиатуры это
не касается — ↑ и ↓ шагают по значению и без
видимых стрелок.
<div class="gr-input-group" style="max-width: 9rem">
<button class="gr-btn" type="button" aria-label="Убрать одну штуку"
onclick="this.nextElementSibling.stepDown()">−</button>
<input class="gr-input gr-input-no-spin gr-text-center" type="number"
inputmode="numeric" value="1" min="1" max="99" aria-label="Количество">
<button class="gr-btn" type="button" aria-label="Добавить одну штуку"
onclick="this.previousElementSibling.stepUp()">+</button>
</div>
stepUp() и stepDown() у самого поляСчётчик у границы: кнопка гаснет сама
Счётчик выше на единице не гаснет: stepDown() просто не даёт
значению уйти ниже min, а кнопка остаётся нажимаемой
и молчащей. Для корзины этого мало — «минус» на единице должен выглядеть
недоступным. Гасит его ваш код: библиотека про остатки на складе
и про минимальную партию ничего не знает.
Ниже — тот же счётчик с max="5", чтобы граница была в двух
нажатиях, и с делегированным обработчиком на всю группу вместо двух
атрибутов: в списке товаров счётчиков десятки, и вешать на каждый
по паре обработчиков незачем. Он же шлёт change —
сами шаговые методы события не шлют.
function grQty(group) {
const input = group.querySelector('input');
const minus = group.querySelector('[data-step="-1"]');
const plus = group.querySelector('[data-step="1"]');
const sync = () => {
minus.disabled = Number(input.value) <= Number(input.min);
plus.disabled = Number(input.value) >= Number(input.max);
};
group.addEventListener('click', (event) => {
const button = event.target.closest('[data-step]');
if (!button) return;
button.dataset.step === '1' ? input.stepUp() : input.stepDown();
input.dispatchEvent(new Event('change', { bubbles: true }));
});
input.addEventListener('input', sync);
input.addEventListener('change', sync);
sync();
}
Событие change шаговые методы не шлют.
Это поведение платформы, а не недосмотр: значение меняет скрипт,
а не пользователь. Если на количество подписан пересчёт корзины,
пошлите событие сами — как в примере выше; подпись рядом со счётчиком
слушает именно его. В первом примере обработчики стоят прямо
в разметке: так блок остаётся самодостаточным при копировании,
и с него удобно начинать.
Класса .gr-qty здесь нет по тем же причинам, что
.gr-price и .gr-sticker: он был бы именем
для готовой группы, а не новым CSS. Единственное, чего не хватало
по-настоящему, — .gr-input-no-spin: снять системные
стрелки композицией утилит нельзя.
С подключённым griffinjs-fields.js
Второй бандл слоя — девять полей, которых у платформы нет. Разметка
та же семья .gr-field, у каждого поля есть база без
скрипта, а griffinjs.js от них не растёт ни на байт:
файл подключают только те, кому он нужен, после griffinjs.js
или griffinjs-core.js. Набор «ядро + anchor + поля» —
13,9 КБ gzip.
Здесь — поля, которые чаще всего стоят в форме рядом;
остальные и подробности — на странице
«Поля форм».
Телефон: код страны и маска
Список кодов — из разметки или из таблицы стран
griffinjs-countries.js; имя страны и флаг — у платформы,
в языке страницы. Без скрипта — type="tel" и тот же список
в группе.
<div class="gr-input-group" data-gr-phone="country: ru">
<select class="gr-select" name="code" aria-label="Код страны"></select>
<input class="gr-input" type="tel" name="phone" autocomplete="tel-national">
</div>
Дата: второй путь и его плата
Разметка — то же нативное поле, что выше. На тач-устройстве виджет
её не трогает: системный календарь остаётся, и решает это
pointerType каждого события. На указателе мыши тип
подменяется на text, надевается маска по локали
из lang документа — формат наконец ваш, а не браузера, —
открывается панель, а ISO уходит в скрытый спутник с прежним именем.
Плата: на текстовом поле платформа не проверяет min
и max — проверяет виджет через setCustomValidity,
сообщение о границах берёт у платформы, о невозможной дате — своё.
<input class="gr-input" type="date" name="from" min="2026-01-01" required data-gr-datetime>
Диапазон «с — по»
Пара нативных полей из раздела «Диапазон
„от — до“», связанная атрибутом to на первом.
Значение «с» — min у «по», значение «по» —
max у «с»: проверка границ, сообщения и недоступные дни
в панели работают тем же путём, что у одиночного поля. Пустое поле
открывает панель на месяце соседа, дни между концами помечены
в обеих панелях. На тач-устройстве работает только связка границ —
системный календарь скрывает недоступное сам. Авторская граница
остаётся, если она уже; очистка возвращает её.
<input class="gr-input" id="a" type="date" name="since" data-gr-datetime="to: #b">
<input class="gr-input" id="b" type="date" name="until" data-gr-datetime>
Сумма с разрядами
Отдельное поле, а не маска: type="number" разрядов
не показывает, inputmode не форматирует, а маска знает
только фиксированные слоты. На экране — «1 250 000,50» по локали
документа, в скрытом спутнике с прежним именем — 1250000.50;
разряды встают на ходу, каретка держится за той же цифрой, вставка
«1 250 000,50» и «1250000.50» читается одинаково. Валюта и единица —
в аддоне рядом. База без скрипта —
обычное текстовое поле: сервер получит то, что набрано, и разбор
на сервере обязателен в любом случае.
<div class="gr-input-group">
<input class="gr-input" type="text" inputmode="decimal" name="amount" min="0" data-gr-number="decimals: 2">
<span class="gr-input-addon">₽</span>
</div>
Счётчик символов
<textarea class="gr-textarea" maxlength="200" data-gr-counter="format: {left} из {max}"></textarea>
aria-live нужен griffinjs-fields.jsСводка ошибок вместо пузыря
<form data-gr-validate>
…
<button class="gr-btn gr-btn-primary" type="submit">Отправить</button>
</form>