Зависимые селектбоксы элегантно в Nette и чистом JavaScript
Как создать связанные селектбоксы, когда после выбора значения в одном динамически загружаются опции в другой? В Nette и чистом JavaScript это простая задача. Мы покажем решение, которое является чистым, повторно используемым и безопасным.

Модель данных
В качестве примера создадим форму, содержащую селектбоксы для выбора страны и города.
Сначала подготовим модель данных, которая будет возвращать элементы для обоих селектбоксов. Вероятно, она будет получать их из базы данных. Точная реализация не важна, поэтому лишь обозначим, как будет выглядеть интерфейс:
class World
{
public function getCountries(): array
{
return ...
}
public function getCities($country): array
{
return ...
}
}
Поскольку общее количество городов
действительно велико, мы будем получать их
с помощью AJAX. Для этой цели создадим
EndpointPresenter
, то есть API, которое будет
возвращать нам города в отдельных странах в
формате JSON:
class EndpointPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private World $world,
) {}
public function actionCities($country): void
{
$cities = $this->world->getCities($country);
$this->sendJson($cities);
}
}
Если бы городов было мало (например, на
другой планете 😉), или если бы модель
представляла данные, которых просто
немного, мы могли бы передать их все сразу
как массив в JavaScript и сэкономить AJAX-запросы.
В таком случае EndpointPresenter
не
понадобился бы.
Форма
А теперь перейдем к самой форме. Создадим
два селектбокса и свяжем их, т.е.
подчиненному (city
) установим элементы
в зависимости от выбранного значения
родительского (country
). Важно, что мы
делаем это в обработчике события onAnchor, то есть в
момент, когда форма уже знает значения,
отправленные пользователем.
class DemoPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private World $world,
) {}
protected function createComponentForm(): Form
{
$form = new Form;
$country = $form->addSelect('country', 'Страна:', $this->world->getCountries())
->setPrompt('----');
$city = $form->addSelect('city', 'Город:');
// <-- сюда потом еще что-нибудь добавим
$form->onAnchor[] = fn() =>
$city->setItems($country->getValue()
? $this->world->getCities($country->getValue())
: []);
// $form->onSuccess[] = ...
return $form;
}
}
Так созданная форма будет работать и без JavaScript. А именно так, что пользователь сначала выберет страну, отправит форму, затем появится список городов, он выберет один из них и отправит форму снова.
Но нас интересует динамическая загрузка
городов с помощью JavaScript. Самый чистый
способ подойти к этому — использовать
data-
атрибуты, в которых мы передадим
в HTML (и, следовательно, в JS) информацию о том,
какие селектбоксы связаны и откуда следует
брать данные.
Каждому подчиненному селектбоксу
передадим атрибут data-depends
с именем
родительского элемента и далее либо
data-url
с URL, откуда он должен получать
элементы с помощью AJAX, либо data-items
,
где все варианты сразу укажем.
Начнем с AJAX-варианта. Передадим имя
родительского элемента country
и ссылку
на Endpoint:cities
. Знак #
используем
как плейсхолдер, и JavaScript будет вместо него
вставлять выбранный пользователем ключ.
$city = $form->addSelect('city', 'Город:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));
А вариант без AJAX? Подготовим массив всех
стран и всех их городов, который передадим в
атрибут data-items
:
$items = [];
foreach ($this->world->getCountries() as $id => $name) {
$items[$id] = $this->world->getCities($id);
}
$city = $form->addSelect('city', 'Город:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-items', $items);
И остается написать обслуживающий JavaScript.
Обработчик JavaScript
Следующий код универсален, он не привязан
к конкретным селектбоксам country
и
city
из примера, а свяжет любые
селектбоксы на странице, достаточно лишь
установить им упомянутые data-
атрибуты.
Код написан на чистом vanilla JS, поэтому не требует jQuery или другой библиотеки.
// найдем на странице все подчиненные селектбоксы
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
let parentSelect = childSelect.form[childSelect.dataset.depends]; // родительский <select>
let url = childSelect.dataset.url; // атрибут data-url
let items = JSON.parse(childSelect.dataset.items || 'null'); // атрибут data-items
// когда пользователь изменяет выбранный элемент в родительском селекте...
parentSelect.addEventListener('change', () => {
// если существует атрибут data-items...
if (items) {
// загружаем сразу в подчиненный селектбокс новые элементы
updateSelectbox(childSelect, items[parentSelect.value]);
}
// если существует атрибут data-url...
if (url) {
// делаем AJAX-запрос на endpoint с выбранным элементом вместо плейсхолдера
fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
.then((response) => response.json())
// и загружаем в подчиненный селектбокс новые элементы
.then((data) => updateSelectbox(childSelect, data));
}
});
});
// перезаписывает <options> в <select>
function updateSelectbox(select, items)
{
select.innerHTML = ''; // удаляем все
for (let id in items) { // вставляем новые
let el = document.createElement('option');
el.setAttribute('value', id);
el.innerText = items[id];
select.appendChild(el);
}
}
Больше элементов и повторное использование
Решение не ограничено двумя селектбоксами, можно создать каскад из трех или более зависимых друг от друга элементов. Например, добавим выбор улицы, который будет зависеть от выбранного города:
$street = $form->addSelect('street', 'Улица:')
->setHtmlAttribute('data-depends', $city->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));
$form->onAnchor[] = fn() =>
$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);
Также несколько селектбоксов могут
зависеть от одного общего. Достаточно лишь
аналогично установить data-
атрибуты и
наполнение элементов с помощью
setItems()
.
При этом нет необходимости вносить какие-либо изменения в JavaScript-код, который работает универсально.
Безопасность
Даже в этих примерах сохраняются все механизмы безопасности, которыми обладают формы в Nette. В частности, каждый селектбокс проверяет, является ли выбранный вариант одним из предложенных, и, следовательно, злоумышленник не может подставить другое значение.
Решение работает в Nette 2.4 и новее,
примеры кода написаны для Nette для PHP 8. Чтобы
они работали в старых версиях, замените продвижение
свойств и fn()
на
function () use (...) { ... }
.
Чтобы оставить комментарий, пожалуйста, войдите в систему