Abhängige Selectboxen elegant in Nette und reinem JavaScript
Wie erstellt man verknüpfte Selectboxen, bei denen nach Auswahl eines Wertes in einer Box dynamisch die Optionen in einer anderen geladen werden? In Nette und reinem JavaScript ist dies eine einfache Aufgabe. Wir zeigen eine Lösung, die sauber, wiederverwendbar und sicher ist.

Datenmodell
Als Beispiel erstellen wir ein Formular mit Selectboxen zur Auswahl von Land und Stadt.
Zuerst bereiten wir das Datenmodell vor, das die Einträge für beide Selectboxen zurückgibt. Wahrscheinlich werden sie aus einer Datenbank bezogen. Die genaue Implementierung ist nicht wesentlich, daher skizzieren wir nur, wie die Schnittstelle aussehen wird:
class World
{
public function getCountries(): array
{
return ...
}
public function getCities($country): array
{
return ...
}
}
Da die Gesamtzahl der Städte sehr groß ist, werden wir sie mittels AJAX
abrufen. Zu diesem Zweck erstellen wir einen EndpointPresenter
,
also eine API, die uns die Städte in den einzelnen Ländern als JSON
zurückgibt:
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);
}
}
Wenn es nur wenige Städte gäbe (vielleicht auf einem anderen Planeten
😉), oder wenn das Modell Daten repräsentieren würde, von denen es einfach
nicht viele gibt, könnten wir sie alle direkt als Array an JavaScript
übergeben und AJAX-Anfragen sparen. In diesem Fall wäre der
EndpointPresenter
nicht notwendig.
Formular
Kommen wir nun zum Formular selbst. Wir werden zwei Auswahlfelder erstellen
und sie miteinander verknüpfen, d. h. wir werden die untergeordneten Elemente
(city
) in Abhängigkeit vom ausgewählten Wert des übergeordneten
Elements (country
) setzen. Wichtig ist, dass wir dies im onAnchor-Ereignishandler
tun, d. h. in dem Moment, in dem das Formular bereits die vom Benutzer
eingegebenen Werte kennt.
class DemoPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private World $world,
) {}
protected function createComponentForm(): Form
{
$form = new Form;
$country = $form->addSelect('country', 'Land:', $this->world->getCountries())
->setPrompt('----');
$city = $form->addSelect('city', 'Stadt:');
// <-- hier fügen wir später noch etwas hinzu
$form->onAnchor[] = fn() =>
$city->setItems($country->getValue()
? $this->world->getCities($country->getValue())
: []);
// $form->onSuccess[] = ...
return $form;
}
}
Ein so erstelltes Formular funktioniert auch ohne JavaScript. Und zwar so, dass der Benutzer zuerst das Land auswählt, das Formular abschickt, dann das Angebot der Städte erscheint, eine davon auswählt und das Formular erneut abschickt.
Uns interessiert aber das dynamische Laden der Städte mittels JavaScript.
Der sauberste Weg, dies anzugehen, ist die Verwendung von
data-
Attributen, in denen wir Informationen an HTML (und damit an
JS) senden, welche Selectboxen verknüpft sind und woher die Daten bezogen
werden sollen.
Jeder untergeordneten Selectbox übergeben wir das Attribut
data-depends
mit dem Namen des übergeordneten Elements und
entweder data-url
mit der URL, von der die Einträge mittels AJAX
bezogen werden sollen, oder data-items
, wo wir alle Varianten
direkt angeben.
Beginnen wir mit der AJAX-Variante. Wir übergeben den Namen des
übergeordneten Elements country
und den Link zu
Endpoint:cities
. Das Zeichen #
verwenden wir als
Platzhalter, und JavaScript wird an seiner Stelle den vom Benutzer gewählten
Schlüssel einfügen.
$city = $form->addSelect('city', 'Stadt:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));
Und die Variante ohne AJAX? Wir bereiten ein Array aller Länder und all
ihrer Städte vor, das wir an das Attribut data-items
übergeben:
$items = [];
foreach ($this->world->getCountries() as $id => $name) {
$items[$id] = $this->world->getCities($id);
}
$city = $form->addSelect('city', 'Stadt:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-items', $items);
Und es bleibt, den JavaScript-Handler zu schreiben.
JavaScript-Handler
Der folgende Code ist universell, er ist nicht an die konkreten Selectboxen
country
und city
aus dem Beispiel gebunden, sondern
verknüpft beliebige Selectboxen auf der Seite, es genügt, ihnen die genannten
data-
Attribute zu setzen.
Der Code ist in reinem Vanilla JS geschrieben, er erfordert also kein jQuery oder eine andere Bibliothek.
// alle untergeordneten Selectboxen auf der Seite finden
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
let parentSelect = childSelect.form[childSelect.dataset.depends]; // übergeordnetes <select>
let url = childSelect.dataset.url; // Attribut data-url
let items = JSON.parse(childSelect.dataset.items || 'null'); // Attribut data-items
// wenn der Benutzer das ausgewählte Element im übergeordneten Select ändert...
parentSelect.addEventListener('change', () => {
// wenn das Attribut data-items existiert...
if (items) {
// laden wir die neuen Elemente direkt in die untergeordnete Selectbox
updateSelectbox(childSelect, items[parentSelect.value]);
}
// wenn das Attribut data-url existiert...
if (url) {
// machen wir eine AJAX-Anfrage an den Endpunkt mit dem ausgewählten Element anstelle des Platzhalters
fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
.then((response) => response.json())
// und laden die neuen Elemente in die untergeordnete Selectbox
.then((data) => updateSelectbox(childSelect, data));
}
});
});
// überschreibt <options> in <select>
function updateSelectbox(select, items)
{
select.innerHTML = ''; // alles entfernen
for (let id in items) { // neue einfügen
let el = document.createElement('option');
el.setAttribute('value', id);
el.innerText = items[id];
select.appendChild(el);
}
}
Mehrere Elemente und Wiederverwendbarkeit
Die Lösung ist nicht auf zwei Selectboxen beschränkt, es kann auch eine Kaskade von drei oder mehr voneinander abhängigen Elementen erstellt werden. Zum Beispiel ergänzen wir die Auswahl der Straße, die von der gewählten Stadt abhängt:
$street = $form->addSelect('street', 'Straße:')
->setHtmlAttribute('data-depends', $city->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));
$form->onAnchor[] = fn() =>
$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);
Es können auch mehrere Selectboxen von einer gemeinsamen abhängen. Es
genügt, analog die data-
Attribute zu setzen und die Einträge
mittels setItems()
zu füllen.
Dabei ist kein Eingriff in den JavaScript-Code erforderlich, der universell funktioniert.
Sicherheit
Auch in diesen Beispielen bleiben alle Sicherheitsmechanismen erhalten, über die Formulare in Nette verfügen. Insbesondere prüft jede Selectbox, ob die ausgewählte Variante eine der angebotenen ist, sodass ein Angreifer keinen anderen Wert unterschieben kann.
Die Lösung funktioniert in Nette 2.4 und höher, die Codebeispiele sind
für PHP 8 geschrieben. Damit sie in älteren Versionen funktionieren, ersetzen
Sie property
promotion und fn()
durch
function () use (...) { ... }
.
Um einen Kommentar abzugeben, loggen Sie sich bitte ein