Des boîtes de sélection dépendantes de façon élégante en Nette et en JS pur

il y a 2 ans de David Grudl  

Comment créer des boîtes sélectives enchaînées, où après avoir sélectionné une valeur dans l'une, les options sont dynamiquement mises à jour dans l'autre ? C'est une tâche facile en Nette et en JavaScript pur. Nous allons vous montrer une solution propre, réutilisable et sécurisée.

Modèle de données

À titre d'exemple, créons un formulaire contenant des cases à cocher pour sélectionner le pays et la ville.

Tout d'abord, nous allons préparer un modèle de données qui renverra les entrées des deux cases de sélection. Il les récupérera probablement dans la base de données. L'implémentation exacte n'étant pas essentielle, nous allons nous contenter d'indiquer à quoi ressemblera l'interface :

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

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

Comme le nombre total de villes est très important, nous les récupérerons en utilisant AJAX. À cette fin, nous allons créer une EndpointPresenter, une API qui renverra les villes de chaque pays en 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);
	}
}

S'il y a peu de villes (par exemple sur une autre planète 😉 ), ou si le modèle représente des données qui ne sont tout simplement pas nombreuses, nous pourrions toutes les passer comme tableau à JavaScript et économiser les requêtes AJAX. Dans ce cas, il n'y aurait pas besoin de EndpointPresenter.

Formulaire

Passons maintenant au formulaire lui-même. Nous allons créer deux boîtes de sélection et les lier, c'est-à-dire que nous allons définir les éléments de l'enfant (city) en fonction de la valeur sélectionnée du parent (country). L'important est que nous le fassions dans le gestionnaire d'événements onAnchor, c'est-à-dire au moment où le formulaire connaît déjà les valeurs soumises par l'utilisateur.

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:');
		// <-- nous allons ajouter quelque chose d'autre ici

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

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

Le formulaire créé de cette manière fonctionnera sans JavaScript. Pour ce faire, l'utilisateur doit d'abord sélectionner un pays, soumettre le formulaire, puis un menu de villes apparaît, il doit en sélectionner une et soumettre à nouveau le formulaire.

Cependant, nous souhaitons charger dynamiquement les villes en utilisant JavaScript. La façon la plus propre d'aborder cette question est d'utiliser les attributs data-, dans lesquels nous envoyons des informations au HTML (et donc au JS) sur les boîtes de sélection qui sont liées et où récupérer les données.

Pour chaque boîte de sélection enfant, nous transmettons un attribut data-depends avec le nom de l'élément parent, puis un attribut data-url avec l'URL à partir de laquelle nous récupérons les éléments à l'aide d'AJAX, ou un attribut data-items où nous énumérons directement toutes les options.

Commençons par la variante AJAX. Nous transmettons le nom de l'élément parent country et une référence à Endpoint:cities. Nous utilisons le caractère # comme caractère de remplacement et JavaScript mettra la clé sélectionnée par l'utilisateur à la place.

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

Et la variante sans AJAX ? Nous préparons un tableau de tous les pays et de toutes ses villes, que nous passons à l'attribut 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);

Gestionnaire JavaScript

Le code suivant est universel, il n'est pas lié aux boîtes de sélection spécifiques country et city de l'exemple, mais il reliera toutes les boîtes de sélection de la page, il suffit de définir les attributs data- mentionnés.

Le code est écrit en pure vanilla JS, il ne nécessite donc pas jQuery ou toute autre bibliothèque.

// trouver toutes les boîtes de sélection enfant sur la page
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <select>
	let url = childSelect.dataset.url; // attribut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // attribut data-items

	// lorsque l'utilisateur change l'élément sélectionné dans la sélection parent...
	parentSelect.addEventListener('change', () => {
		// si l'attribut data-items existe...
		if (items) {
			// charger les nouveaux éléments directement dans la boîte de sélection enfant
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// si l'attribut data-url existe...
		if (url) {
			// nous faisons une requête AJAX vers le point de terminaison avec l'élément sélectionné au lieu du placeholder.
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// et chargeons les nouveaux éléments dans la boîte de sélection enfant
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// remplace <options> dans <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // supprime tout
	for (let id in items) { // insérer le nouveau
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Plus d'éléments et de possibilités de réutilisation

La solution ne se limite pas à deux boîtes de sélection, vous pouvez créer une cascade de trois éléments dépendants ou plus. Par exemple, nous ajoutons une sélection de rue qui dépend de la ville sélectionnée :

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

De même, plusieurs boîtes de sélection peuvent dépendre d'une seule boîte commune. Il suffit de définir les attributs data- par analogie et de remplir les éléments avec setItems().

Il n'est absolument pas nécessaire de modifier le code JavaScript, qui fonctionne universellement.

Sécurité

Même dans ces exemples, tous les mécanismes de sécurité dont disposent les formulaires Nette sont préservés. En particulier, chaque boîte de sélection vérifie que l'option sélectionnée est l'une de celles proposées, et un attaquant ne peut donc pas usurper une valeur différente.


La solution fonctionne avec Nette 2.4 et plus, les exemples de code sont écrits pour PHP 8. Pour les faire fonctionner dans des versions plus anciennes, remplacez property promotion et fn() par function () use (...) { ... }.