Selectboxes dependentes elegantemente em Nette e JavaScript puro

há 3 anos De David Grudl  

Como criar selectboxes interligados, onde após escolher um valor em um, as opções do outro são carregadas dinamicamente? Em Nette e JavaScript puro, esta é uma tarefa fácil. Mostraremos uma solução limpa, reutilizável e segura.

Modelo de dados

Como exemplo, criaremos um formulário contendo selectboxes para escolher país e cidade.

Primeiro, prepararemos o modelo de dados que retornará os itens para ambos os selectboxes. Provavelmente, ele os obterá de um banco de dados. A implementação exata não é importante, por isso apenas indicaremos como será a interface:

class World
{
	public function getCountries(): array
	{
		return ...
	}

	public function getCities($country): array
	{
		return ...
	}
}

Como o número total de cidades é realmente grande, vamos obtê-las usando AJAX. Para este fim, criaremos um EndpointPresenter, ou seja, uma API que nos retornará as cidades de cada país como 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);
	}
}

Se houvesse poucas cidades (talvez em outro planeta 😉), ou se o modelo representasse dados que simplesmente não são muitos, poderíamos passá-los todos de uma vez como um array para o JavaScript e economizar requisições AJAX. Nesse caso, o EndpointPresenter não seria necessário.

Formulário

E passemos à forma propriamente dita. Vamos criar duas caixas de seleção e ligá-las, ou seja, vamos definir os itens da criança (city) dependendo do valor selecionado dos pais (country). O importante é que façamos isso no manipulador de eventos onAnchor, ou seja, no momento em que o formulário já conhece os valores enviados pelo usuário.

class DemoPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private World $world,
	) {}

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$country = $form->addSelect('country', 'País:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'Cidade:');
		// <-- adicionaremos algo aqui mais tarde

		$form->onAnchor[] = fn() =>
			$city->setItems($country->getValue()
				? $this->world->getCities($country->getValue())
				: []);

		// $form->onSuccess[] = ...
		return $form;
	}
}

Um formulário criado desta forma funcionará mesmo sem JavaScript. E funciona assim: o usuário primeiro seleciona o país, envia o formulário, depois aparece a oferta de cidades, ele seleciona uma delas e envia o formulário novamente.

Mas estamos interessados no carregamento dinâmico de cidades usando JavaScript. A maneira mais limpa de abordar isso é usar atributos data-, nos quais enviamos informações para o HTML (e, por extensão, JS) sobre quais selectboxes estão interligados e de onde os dados devem ser obtidos.

Para cada selectbox dependente, passaremos o atributo data-depends com o nome do elemento pai e, em seguida, data-url com a URL de onde obter os itens via AJAX, ou data-items, onde listamos todas as variantes diretamente.

Comecemos com a variante AJAX. Passamos o nome do elemento pai country e o link para Endpoint:cities. Usamos o caractere # como placeholder e o JavaScript inserirá a chave selecionada pelo usuário em seu lugar.

$city = $form->addSelect('city', 'Cidade:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));

E a variante sem AJAX? Prepararemos um array de todos os países e todas as suas cidades, que passaremos para o atributo data-items:

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

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

E resta escrever o manipulador JavaScript.

Manipulador JavaScript

O código a seguir é universal, não está vinculado aos selectboxes específicos country e city do exemplo, mas interligará quaisquer selectboxes na página, basta definir os atributos data- mencionados.

O código está escrito em vanilla JS puro, portanto não requer jQuery ou outra biblioteca.

// encontramos todos os selectboxes filhos na página
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // <select> pai
	let url = childSelect.dataset.url; // atributo data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atributo data-items

	// quando o usuário altera o item selecionado no select pai...
	parentSelect.addEventListener('change', () => {
		// se o atributo data-items existir...
		if (items) {
			// carregamos diretamente novos itens no selectbox filho
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// se o atributo data-url existir...
		if (url) {
			// fazemos uma requisição AJAX para o endpoint com o item selecionado em vez do placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// e carregamos novos itens no selectbox filho
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// sobrescreve <options> em <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // removemos tudo
	for (let id in items) { // inserimos novos
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Mais elementos e reutilização

A solução não está limitada a dois selectboxes, é possível criar uma cascata de três ou mais elementos dependentes entre si. Por exemplo, adicionamos a escolha da rua, que dependerá da cidade selecionada:

$street = $form->addSelect('street', 'Rua:')
	->setHtmlAttribute('data-depends', $city->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));

$form->onAnchor[] = fn() =>
	$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);

Também podem existir vários selectboxes dependendo de um comum. Basta definir analogamente os atributos data- e preencher os itens usando setItems().

Não é necessário fazer nenhuma alteração no código JavaScript, que funciona universalmente.

Segurança

Mesmo nestes exemplos, todos os mecanismos de segurança que os formulários Nette possuem são mantidos. Principalmente, que cada selectbox verifica se a variante selecionada é uma das oferecidas e, portanto, um atacante não pode submeter um valor diferente.


A solução funciona em Nette 2.4 e posteriormente, as amostras de código são escritas para o PHP 8. Para fazê-los funcionar em versões antigas, substitua promoção de propriedade e fn() por function () use (...) { ... }.