Függő selectboxok elegánsan Nette-ben és tiszta JavaScriptben

3 éve írta David Grudl  

Hogyan hozzunk létre összekapcsolt selectboxokat, ahol az egyikben lévő érték kiválasztása után dinamikusan betöltődnek a választási lehetőségek a másikba? Nette-ben és tiszta JavaScriptben ez egy egyszerű feladat. Bemutatunk egy tiszta, újrafelhasználható és biztonságos megoldást.

Adatmodell

Példaként létrehozunk egy űrlapot, amely selectboxokat tartalmaz az ország és a város kiválasztásához.

Először elkészítjük az adatmodellt, amely visszaadja az elemeket mindkét selectboxhoz. Valószínűleg adatbázisból fogja őket lekérni. A pontos implementáció nem lényeges, ezért csak felvázoljuk, hogyan fog kinézni az interfész:

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

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

Mivel a városok teljes száma valóban nagy, AJAX segítségével fogjuk őket lekérni. Erre a célra létrehozunk egy EndpointPresenter-t, azaz egy API-t, amely JSON formátumban adja vissza nekünk az egyes országok városait:

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);
	}
}

Ha kevés város lenne (például egy másik bolygón 😉), vagy ha a modell olyan adatokat reprezentálna, amelyekből egyszerűen nincs sok, akkor mindet átadhatnánk közvetlenül tömbként a JavaScriptnek, és megspórolhatnánk az AJAX kéréseket. Ebben az esetben nem lenne szükség az EndpointPresenter-re.

Űrlap

És térjünk rá magára az űrlapra. Létrehozunk két selectboxot, és összekapcsoljuk őket, azaz a gyermek (city) elemeit a szülő (country) kiválasztott értékétől függően állítjuk be. Fontos, hogy ezt az onAnchor eseménykezelőjében tesszük, tehát abban a pillanatban, amikor az űrlap már ismeri a felhasználó által elküldött értékeket.

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

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$country = $form->addSelect('country', 'Ország:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'Város:');
		// <-- ide majd még kiegészítünk valamit

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

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

Az így létrehozott űrlap JavaScript nélkül is működni fog. Mégpedig úgy, hogy a felhasználó először kiválasztja az országot, elküldi az űrlapot, majd megjelenik a városok kínálata, kiválaszt egyet közülük, és újra elküldi az űrlapot.

De minket a városok dinamikus betöltése érdekel JavaScript segítségével. Ennek legtisztább módja a data- attribútumok használata, amelyekben információt küldünk a HTML-nek (és ezáltal a JS-nek) arról, hogy mely selectboxok vannak összekapcsolva, és honnan kell adatokat szerezniük.

Minden gyermek selectboxnak átadjuk a data-depends attribútumot a szülő elem nevével, és továbbá vagy a data-url-t az URL-lel, ahonnan AJAX segítségével kell lekérnie az elemeket, vagy a data-items-t, ahol az összes változatot rögtön megadjuk.

Kezdjük az AJAX-os változattal. Átadjuk a szülő elem country nevét és a linket az Endpoint:cities-re. A # jelet placeholderként használjuk, és a JavaScript helyette a felhasználó által kiválasztott kulcsot fogja beilleszteni.

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

És a változat AJAX nélkül? Elkészítünk egy tömböt az összes országról és azok összes városáról, amelyet átadunk a data-items attribútumba:

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

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

És már csak a kiszolgáló JavaScript megírása van hátra.

JavaScript kiszolgáló

A következő kód univerzális, nem kötődik a példában szereplő konkrét country és city selectboxokhoz, hanem bármilyen selectboxot összekapcsol az oldalon, csak be kell állítani nekik az említett data- attribútumokat.

A kód tiszta vanilla JS-ben íródott, tehát nem igényel jQuery-t vagy más könyvtárat.

// megtaláljuk az oldalon az összes gyermek selectboxot
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // szülő <select>
	let url = childSelect.dataset.url; // data-url attribútum
	let items = JSON.parse(childSelect.dataset.items || 'null'); // data-items attribútum

	// amikor a felhasználó megváltoztatja a kiválasztott elemet a szülő selectben...
	parentSelect.addEventListener('change', () => {
		// ha létezik a data-items attribútum...
		if (items) {
			// rögtön betöltjük a gyermek selectboxba az új elemeket
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// ha létezik a data-url attribútum...
		if (url) {
			// AJAX kérést intézünk az endpoint felé a kiválasztott elemmel a placeholder helyett
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// és betöltjük a gyermek selectboxba az új elemeket
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// felülírja a <options>-t a <select>-ben
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // mindent eltávolítunk
	for (let id in items) { // újakat illesztünk be
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Több elem és újrafelhasználhatóság

A megoldás nem korlátozódik két selectboxra, létrehozhatunk akár három vagy több egymástól függő elemből álló kaszkádot is. Például kiegészítjük az utca választásával, amely a kiválasztott várostól függ:

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

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

Több selectbox is függhet egy közös elemtől. Csak analóg módon be kell állítani a data- attribútumokat és az elemek feltöltését a setItems() segítségével.

Mindeközben nincs szükség semmilyen beavatkozásra a JavaScript kódban, amely univerzálisan működik.

Biztonság

Ezekben a példákban is megmaradnak a Nette űrlapjaiban rejlő összes biztonsági mechanizmus. Különösen az, hogy minden selectbox ellenőrzi, hogy a kiválasztott változat az egyik felkínált-e, és így a támadó nem tud más értéket becsempészni.


A megoldás Nette 2.4-ben és újabb verziókban működik, a kódpéldák Nette for PHP 8-hoz íródtak. Ahhoz, hogy régebbi verziókban működjenek, cserélje le a property promotion és a fn()-t function () use (...) { ... }-re.

David Grudl An artificial intelligence and web technology specialist, creator of the Nette Framework and other popular open-source projects. He writes for Uměligence, phpFashion, and La Trine blogs. He conducts AI training workshops and hosts the Tech Guys show. He's passionate about making artificial intelligence accessible through clear, practical explanations. Creative and pragmatic, he has a keen eye for real-world technology applications.