Caixas de seleção dependentes elegantemente em Nette e JS puro

há 2 anos De David Grudl  

Como criar caixas de seleção encadeadas, onde após selecionar um valor em uma as opções são atualizadas dinamicamente na outra? Esta é uma tarefa fácil em Nette e puro JavaScript. Mostraremos uma solução que é limpa, reutilizável e segura.

Modelo de dados

Como exemplo, vamos criar um formulário contendo caixas de seleção para selecionar o país e a cidade.

Primeiro, vamos preparar um modelo de dados que retornará as entradas para ambas as caixas de seleção. Ele provavelmente as recuperará do banco de dados. A implementação exata não é essencial, então vamos apenas dar uma dica sobre 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 recuperá-las usando AJAX. Para este fim, criaremos um EndpointPresenter, um API que 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 há poucas cidades (por exemplo, em outro planeta 😉), ou se o modelo representa dados que simplesmente não são muitos, poderíamos passar todos eles como array para JavaScript e salvar as solicitações AJAX. Nesse caso, não haveria necessidade de EndpointPresenter.

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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'City:');
		// <-- acrescentaremos algo mais aqui

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

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

O formulário criado desta forma funcionará sem JavaScript. Isto é feito fazendo com que o usuário primeiro selecione um país, envie o formulário, depois aparecerá um menu de cidades, selecione uma delas, e envie o formulário novamente.

Entretanto, estamos interessados em carregar dinamicamente as cidades usando JavaScript. A maneira mais limpa de abordar isto é usar os atributos data-, nos quais enviamos informações ao HTML (e, portanto, ao JS) sobre quais caixas de seleção estão ligadas e de onde recuperar os dados.

Para cada caixa de seleção infantil, passamos um atributo data-depends com o nome do elemento pai, e depois ou um data-url com a URL de onde recuperar os itens usando AJAX, ou um atributo data-items onde listamos todas as opções diretamente.

Vamos começar com a variante AJAX. Passamos o nome do elemento pai country e uma referência para Endpoint:cities. Usamos o caractere # como um espaço reservado e o JavaScript colocará a chave selecionada pelo usuário em seu lugar.

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

E a variante sem AJAX? Preparamos um conjunto de todos os países e todas as suas cidades, que passamos para o atributo 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);

Manipulador de JavaScript

O código a seguir é universal, não está vinculado às caixas de seleção específicas country e city do exemplo, mas ligará quaisquer caixas de seleção na página, basta definir os atributos data- mencionados.

O código é escrito em baunilha pura JS, portanto não requer jQuery ou qualquer outra biblioteca.

// encontre todas as caixas de seleção de crianças na página
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // pai <select>
	let url = childSelect.dataset.url; // atributo dataurl
	let items = JSON.parse(childSelect.dataset.items || 'null'); // dados de atributos

	// quando o usuário muda o item selecionado na seleção pai.
	parentSelect.addEventListener('change', () => {
		// se o atributo de itens de dados existir...
		if (items) {
			// carregar novos itens diretamente na caixa de seleção filha
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// se o atributo "dataurl" existir...
		if (url) {
			// fazemos uma solicitação AJAX ao ponto final com o item selecionado, em vez de ao titular do lugar
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// e carregar novos itens na caixa de seleção para crianças
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

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

Mais elementos e reusabilidade

A solução não se limita a duas caixas de seleção, você pode criar uma cascata de três ou mais elementos dependentes. Por exemplo, adicionamos uma seleção de rua que depende da cidade selecionada:

$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()) : []);

Além disso, várias caixas de seleção podem depender de uma única caixa comum. Basta definir os atributos data- por analogia e povoar os itens com setItems().

Não há absolutamente nenhuma necessidade de fazer qualquer modificação no código JavaScript, que funciona universalmente.

Segurança

Mesmo nestes exemplos, todos os mecanismos de segurança que a Nette possui ainda estão preservados. Em particular, cada caixa de seleção verifica se a opção selecionada é uma das oferecidas, e assim um atacante não pode falsificar 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 (...) { ... }.