Caixas de seleção dependentes elegantemente em Nette e JS puro
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', $this->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 (...) { ... }
.
Para enviar um comentário, faça o login