Зависимые селекбоксы элегантно в Nette и чистом JS

2 года назад от David Grudl  

Как создать цепочку селекбоксов, в которых после выбора значения в одном из них опции динамически обновляются в другом? Это простая задача в 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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', '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', 'City:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-url', $country->link('Endpoint:cities', '#'));

А вариант без AJAX? Подготовим массив всех стран и всех его городов, который передадим в атрибут data-items:

$items = [];
foreach ($this->world->getCountries() as $id => $name) {
	$items[$id] = $this->world->getCities($id);
}

$city = $form->addSelect('city', 'City:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', $items);

Обработчик JavaScript

Следующий код является универсальным, он не привязан к конкретным полям выбора country и city из примера, но он свяжет любые поля выбора на странице, просто установите упомянутые атрибуты data-.

Код написан на чистом ванильном 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 запрос к конечной точке с выбранным элементом вместо placeholder
			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', 'Ulice:')
	->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 (...) { ... }..

Последние сообщения