Залежні селектбокси елегантно в 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-запит до ендпоінта з вибраним елементом замість плейсхолдера
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 і вище, приклади
коду написані для PHP 8. Щоб вони працювали в
старіших версіях, замініть property
promotion і fn()
на
function () use (...) { ... }
..
Щоб залишити коментар, будь ласка, увійдіть до системи