Benutzer:Rolf b/DragDropDemo

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Das folgende Programmbeispiel nutzt das Drag&Drop API von HTML 5, um ein Zuordnungsquiz zu realisieren. Eine Funktionsfähigkeit unter alten Browsern wie dem Internet Explorer wird nicht angestrebt, darum werden teils auch neuere JavaScript Features verwendet.

HTML Rahmen

Es verwendet dafür einen einfachen HTML Rahmen:

Beispiel
<main id="quiz">
  <h1>Aufgabe</h1>
  <p id="comment"></p>
  <dl id="questions"></dl>
  <ul id="answers" class="answer-drop"></ul>
  <div id="assignPopup"></div>
  <button id="nextTask">Neue Aufgabe</button>
</main>

Die Befüllung des HTML mit Inhalten erledigt das JavaScript-Modul. Um hier nicht zu viele neue Dinge zu zeigen, und um den Code in das Frickl-System von Selfhtml integrieren zu können, wird auf Software-Engineering Techniken wie Klassen und Modularisierung verzichtet. Unser Script wird aus einer Sammlung von Funktionen bestehen und einer Prise Initialisierungscode, der die Funktionen mit DOM Events verknüpft.

Bitte beachten Sie, dass das dropzone Attribut nicht verwendet wird. Es war eine Zeitlang in HTML 5 enthalten, ist aber in HTML 5.2 wieder gestrichen worden. Als Alternative wird eine Klasse zur Markierung der Drop-Ziele verwendet. Die Verwendung einer solchen Markierung ist nicht zwingend, man kann auch durch JavaScript Programmierung erkennen, ob ein HTML Element für einen Drop zulässig ist. Der Plan soll hier aber sein, das Script einfach zu halten und die Feststellung einer möglichen Ablagestelle für Antworten im HTML zu markieren.

Aufgaben

In einem Quiz sind Aufgaben zu lösen. Das hier vorgestellte Zuordnungsquiz verwendet dafür ein Objekt mit dem folgenden Aufbau:

Struktur einer Aufgabe
let demoAufgabe = {
   task: "Ordnen Sie den Aufgaben die richtigen Lösungen zu",
   comment: "Bitte verzichten Sie zur Lösung auf den Einsatz eines Taschenrechners.",
   questions: [ "4+7", "2·3", "42÷6", "7²", "cos π" ],
   answers: [ "-1", "7", "6", "11", "49" ],
   mapping: [ 4, 2, 1, 0, 3 ]
}
Damit die Fragen den Antworten nicht 1:1 entsprechen, sind die Antworten gemischt worden. Das mapping Array gibt an der i-ten Position an, welche Frage zu Antwort i gehört. An Index 1 steht eine 2, d.h. zur Antwort am Index 1 (7) gehört die Frage am Index 2 (42÷6). Das Mischen der Antworten und die Erzeugung des Mapping-Arrays sind nicht Thema dieses Artikels.

Um solche Aufgaben zu erhalten, könnte das Script einen Webservice nutzen, der zufällig eine Aufgabe auswählt und als JSON-String liefert. An dieser Stelle kann man beliebig komplexe Szenarien erfinden, mit User-Login, individuellen Aufgabenstellungen, Gruppenwettbewerben und Statistiken ohne Ende. Aber wir wollen ja eigentlich nur Drag und Drop zeigen.

Die erste Funktion unseres Scripts ist also die Ermittlung der zu bearbeitenden Aufgabe und die Initialisierung der Seite für diese Aufgabe. Das ist soweit kein Hexenwerk - ein click-Handler auf einem Button, der mit fetch Daten holt und damit eine Funktion aufruft. Wenn aufgabe.php das Serverscript wäre, das Aufgaben als JSON String liefert, könnte das so aussehen:

Funktion nextTask
function nextTask() {
   fetch("aufgabe.php")
   .then(response => response.json())
   .then(task => init(task));
}
Im Initialisierungsteil des Scripts steht noch die Registrierung dieser Funktion als click-Handler, und ein einmaliger Direktaufruf, um beim Laden der Seite sofort eine Aufgabe vorzufinden
document.getElementById("nextTask").addEventListener("click", nextTask);
nextTask();
In Ermangelung eines Server-Scripts könnte nextTask - mit dem let aufgabe=... von vorhin - auch einfach einen Service simulieren:
function nextTask() {
   init(demoAufgabe);
}

Wie man mittels des fetch-API ein JSON Dokument holt, dazu finden Sie hier einige grundlegende Informationen.

Erstellen des HTML Dokuments für eine Aufgabe

Die von nextTask aufgerufene init-Funktion muss nun aus dem Aufgaben-Objekt das HTML Dokument des quiz aufbauen. Das Ergebnis soll so aussehen. Es ist eine manuell erstellte Demo, noch ohne JavaScript.

HTML Aufbau einer Quiz-Aufgabe ansehen …
<main id="quiz">
  <h1>Ordnen Sie den Aufgaben die richtigen Lösungen zu</h1>
  <p id="comment">Bitte verzichten Sie zur Lösung auf den Einsatz eines Taschenrechners.</p>
  <dl id="questions">
    <dt>4+7</dt><dd class="answer-drop" data-ref="0"></dd>
    <dt>2·3</dt><dd class="answer-drop" data-ref="1"></dd>
    <!-- und so weiter -->
  </dl>
  <ul id="answers" class="answer-drop">
    <li><button type="button" draggable="true" value="0">-1</button></li>
    <li><button type="button" draggable="true" value="1">7</button></li>
    <!-- und so weiter -->
  </ul>
</main>

Das sieht alles sehr übersichtlich aus, es hat aber noch einen ärgerlichen Fehler: Browser auf Mobilgeräten unterstützen das API nicht, man muss mit dem Touch-API nachhelfen. Chrome ab Version 85, auf Android-Version ab 7, soll es aber unterstützen. In der vorliegenden Fassung des Quiz-Scripts ist das Touch-API nicht angebunden.

Die Idee bei diesem Aufbau ist, dass die Fragen semantisch als Description List dargestellt werden. Je Frage gibt es eine dt/dd Gruppe. Durch display:grid wird dafür gesorgt, dass Fragen und Antwortfelder nebeneinander stehen. Unterhalb des Fragen-Rasters befindet sich eine Liste mit den Antwortmöglichkeiten, die per Drag und Drop den Fragen zugeordnet werden sollen. Um später noch eine Tastaturbedienung hinzufügen zu können, sind diese Antworten als Buttons vorgesehen. Klicken Sie auf "ausprobieren", um sich das verwendete Stylesheet anzuschauen.

Erwähnen sollte man, dass die dd-Elemente, die als Dropzone dienen, ein data-ref Attribut mit der laufenden Nummer der Frage erhalten, und die Button-Elemente, die für die Antworten stehen, in ihrem value-Attribut die Nummer der Antwort bekommen, für die sie stehen. Auf diese Weise ist bei der Ergebnisauswertung ohne weiteres Suchen oder Prüfen klar, welche Antwort welcher Frage zugeordnet wurde.

Sowohl die dd-Elemente als auch die "answers" Liste bekommen die answer-drop Klasse. Das ermöglicht das Zurückschieben einer Antwort in die Liste der offenen Antworten.

Wie sieht nun der JavaScript-Code aus, der dies aus einer Aufgabe aufbaut? Sie müssen diesen Code jetzt nicht genauer studieren - es ist mühsames Erzeugen und Zusammenstecken von HTML-Elementen und Eigenschaften, so dass das oben gezeigte HTML herauskommt. Das Erstellen von HTML Elementen mittels JavaScript war schon in anderen Tutorials Thema und wird hier als bekannt vorausgesetzt.

Beispiel
// Ablegen oft benutzter DOM Elemente in globalen Variablen
const quiz = document.getElementById("quiz"),
      questions = document.getElementById("questions"),
      answers = document.getElementById("answers");
// Die zu bearbeitende Aufgabe wird ebenfalls öfter gebraucht
let   aufgabe;

function init(aufg) {
   aufgabe = aufg;
   
   // (todo: Entfernen alter Inhalte aus einer vorigen Augabe)

   quiz.querySelector("h1").textContent = aufgabe.task;
   quiz.querySelector("#comment").textContent = aufgabe.comment || '';  // Der Kommentar kann auch fehlen

   // Liste aus Fragestellungen und Dropzonen für die Antworten erzeugen.
   // Pro Durchlauf wird ein <dt>-<dd> Paar erzeugt
   aufgabe.questions.forEach(
      (frageText, frageNr) => {
         questions.appendChild(createQuestion(frageText));
         questions.appendChild(createAnswerDrop(frageNr));
         // (todo: Frageselektor für Tastaturunterstützung erzeugen)
      });

   // Liste aus Buttons für die Antworten erzeugen
   aufgabe.answers.forEach( 
      (antwortText, antwortNr) => appendToAnswers(createAnswerButton(antwortText, antwortNr))
   );

   function createQuestion(frageText) {
      const questionElement = document.createElement("dt");
      questionElement.innerHTML = frageText;
      return questionElement;
   }

   function createAnswerDrop(referenzNummer) {
      const drop = document.createElement("dd");
      drop.className = "answer-drop";
      drop.dataset.ref = referenzNummer;
      return drop;
   }

   function createAnswerButton(antwortText, referenzNummer) {
      let answerButton = document.createElement("button");
      answerButton.className = "answer";
      answerButton.innerHTML = antwortText;
      answerButton.draggable = true;
      answerButton.value = referenzNummer;
      return answerButton;
   }
}

function appendToAnswers(answer) {
    const item = document.createElement("li");
    item.appendChild(answer);
    answers.appendChild(item);
}

DOM Ereignisse bei Drag und Drop

Eine Drag&Drop Operation besteht aus 3 Schritten, die von entsprechenden DOM Events begleitet werden.

  1. Beginn des Ziehens - dragstart: Der Anwender hat ein DOM Element mit dem Attribut draggable="true" ausgewählt und die erste Bewegung durchgeführt. Das Auswählen kann durch Klicken und Halten mit der Maus geschehen, oder auf Touch-Geräten durch Auflegen und Verschieben eines Fingers.
  2. Während des Ziehens muss man zwischen Events aus Sicht des gezogenen Elements und Events aus Sicht der dabei berührten Elemente unterscheiden
    • drag: Während des Ziehens feuert der Browser bei jeder Positionsveränderung des gezogenen Elements drag Ereignisse. Das Event-Target dieser Ereignisse ist dabei das gezogene Element. Welche DOM Elemente das Element gerade überlagert, muss an Hand der Mausposition ermittelt werden.
    • dragenter, dragover und dragleave: Diese Ereignisse werden auf den Elementen ausgelöst, die ein gezogenes Element betritt, überfährt oder verlässt. Das heißt: das Event-Target ist hier das fremde Element, das vom Ziehen berührt wird. Dafür ist das gezogene Element nicht bekannt
  3. Abschluss des Ziehens - auch hier gibt es wieder die beiden Sichten
    • dragend: Das gezogene Element wird benachrichtigt, dass die Zieh-Operation beendet ist.
    • drop: Das Element, auf dem abgelegt wird, wird benachrichtigt, dass Daten abholen kann

Das bei Mozilla erwähnte dragexit Event existiert in der HTML 5 Spezifikation (Living Standard, 04.11.2020) nicht.

Es geht los - dragstart

Sobald ein Drag&Drop Vorgang beginnt, wird das gezogene Element mit dem dragstart Event benachrichtigt und erhält ein Event-Objekt vom Typ DragEvent. Es hat nun verschiedene Möglichkeiten:

  • Abbrechen des Drag-Vorgangs, durch Aufruf von preventDefault() auf dem DragEvent Objekt
  • Akzeptieren des Drag-Vorgangs, setzen der erlaubten Drop-Effekte und Bereitstellen von Daten

Um den Drop-Effekt zu setzen und Daten bereitzustellen, verwendet man das DataTransfer-Objekt, das durch die Eigenschaft dataTransfer des DragEvent Objekts verfügbar ist.

Welche Drop Operationen zulässig sind, kann mittels der Eigenschaft dataTransfer.effectAllowed festgelegt werden. Das Script soll die Antworten immer verschieben, darum wird die Eigenschaft auf "move" gesetzt.

Als zu übertragendes Datenelement kann man nicht direkt das Button-Element verwenden, weil das API hier einen String erwartet. Daher setzen wie den Wert des value-Attributs am Button, also die laufende Antwortnummer, für die der Button steht. Bei setData ist auch die Angabe eines Formatnamens erforderlich, weil es zulässig ist, das zu übergebene Datenelement in unterschiedlichen Formen bereitzustellen. Üblicherweise verwendet man hier MIME-Typen, und für das Quiz benutzen wir einen privaten Medientyp "x-data" mit Subtyp "answer-number". Dieser Formatnamen wird in einer Konstanten bereitgestellt.

Behandlung des dragStart-Events
const answerFormat = "x-data/answer-number";

function handleDragStartEvent(dragEvent) {
    // Target bei dragStart ist der Antwort-Button. Dessen value als Data-Item setzen
   dragEvent.dataTransfer.setData(answerFormat, dragEvent.target.value);
   dragEvent.dataTransfer.effectAllowed = "move";
}
// ...
quiz.addEventListener("dragstart", handleDragStartEvent);

Unterwegs - dragover, dragenter, dragleave

In diesem Teil sollen zwei bestimmte Aufgaben behandelt werden. Zum einen muss entschieden werden, ob ein Drop des gezogenen Elements möglich ist, und zum anderen soll visualisiert werden, auf welches Element ein Drop erfolgt. Rein hypothetisch könnte ein zu Ende gedachtes und implementiertes Drag&Drop API diese Aufgaben auch selbstständig erledigen. Aber es ist weder das eine, noch das andere.

Die Feststellung, ob ein Drop möglich ist, muss nach jeder Ortsveränderung des gezogenen Elements neu geprüft werden. Dafür bieten sich das drag und das dragover Event an, weil beide nach jeder Ortsveränderung gefeuert werden. Allerdings ist im drag-Event das potenzielle Zielelement nicht ohne Weiteres bekannt, darum soll das dragover Event verwendet werden. Die Handler-Funktion dafür sieht so aus:

Behandlung des dragover Events
const answerFormat = "x-data/answer-number";

function handleDragOverEvent(dragEvent) {
   // Target bei dragOver ist der Drop-Container
   const dZone = getDropzone(dragEvent);
   if (!dZone || !isDraggedAnswer(dragEvent)) return;

   dragEvent.preventDefault();
}

// Helper: Feststellen, ob das DragEvent für eine vorgesehene Drop-Zone erzeugt wurde
function getDropzone(ev) {
   if (!ev || !ev.target) return undefined;
   if (!ev.target.classList.contains("answer-drop")) return undefined;
   return ev.target;
}

// Helper: Feststellen, ob das DragEvent Daten über das Ziehen einer Antwort enthält
function isDraggedAnswer(dragEvent) {
   return dragEvent.dataTransfer.types.includes(answerFormat);
}

quiz.addEventListener("dragover", handleDragOverEvent);

Um möglichst wenig Mühe mit der Registrierung der Eventhandler zu haben, wird der dragover-Handler auf dem quiz-Element registriert. Die dragover-Events gelangen durch den Bubbling-Mechanismus dorthin. Eine solche Vorgehensweise ist vor allem dann von Vorteil, wenn Drop-Ziele im Verlauf des Scripts dynamisch erzeugt werden, man kann dann nicht versäumen, für jedes Ziel die Eventhandler zu registrieren.

Der Nachteil dieser Vorgehensweise ist, dass man im Eventhandler zunächst feststellen muss, ob das Event für ein Element ausgelöst wurde, für das der Handler gedacht ist. Das wird durch die Hilfsfunktion getDropzone erledigt. Sie stellt fest, ob auf dem Element, dass dragover ausgelöst hat, die Klasse answer-drop gesetzt ist. Wenn nicht, wird undefined zurückgegeben.

Eine weitere Prüfung betrifft die Frage, ob gerade ein Element gezogen wird, das für diese Dropzone von Interesse ist. Der Browser gestattet jederzeit das Ziehen von Bildern oder markiertem Text, damit können wir im Quiz nichts anfangen. Zwar könnte man auch im dragStart Event verhindern, dass ein solcher Drag-Vorgang überhaupt beginnt, das wäre aber eine unnötige Bevormundung des Anwenders. Darum prüft die isDraggedAnswer-Funktion, ob in den Datentypen des DataTransfer-Objekts der spezielle Datentyp für Quiz-Antworten abgelegt wurde.

An dieser Stelle ist zu erwähnen, dass das DataTransfer Objekt in dieser Phase in einem geschützten Zustand ist. Die mit setData abgelegten Daten können jetzt weder gelesen noch verändert werden. Es ist lediglich möglich, die gespeicherten Typen abzufragen, und die dropEffect-Eigenschaft zu verändern.

Wichtig ist es, das dropover-Event mit preventDefault abzubrechen. Denn die Default-Behandlung dieses Events besteht darin, den dropEffect abzuschalten.

Die zweite Teilaufgabe besteht darin, das Drop-Ziel visuell hervorzuheben. Auch mit dieser Aufgabe lässt und das Drag&Drop API alleine. Mit den beiden Hilfsfunktionen aus der dragover-Eventbehandlung ist es aber nicht sonderlich schwierig:

Behandlung des dragover Events
function handleDragEnterEvent(dragEvent) {
   // Target bei dragOver ist der Drop-Container
   const dZone = getDropzone(dragEvent);
   if (!dZone || !isDraggedAnswer(dragEvent)) return;
   dZone.classList.add("dropping");
}

function handleDragEnterEvent(dragEvent) {
   // Target bei dragOver ist der Drop-Container
   const dZone = getDropzone(dragEvent);
   if (!dZone || !isDraggedAnswer(dragEvent)) return;
   dZone.classList.remove("dropping");
}

Wie die Hervorhebung erfolgen soll, bleibt dem Stylesheet überlassen.

Am Ziel - drop

Wenn der Wert von dropEffect einen Drop erlaubt und die Maustaste losgelassen wird, dann wird auf dem Element, über dem der Mauszeiger steht, das drop-Event ausgelöst. Der Handler dieses Events ist dafür zuständig, an Hand der Daten im DataTransfer-Objekt und des Wertes in dropEffect die gewünschte Operation auszulösen.

Leider funktioniert das so wie beschrieben nur im Firefox. In Chrome ist es so, dass der Wert von dropEffect im dragover-Eventhandler selbst bestimmt und gesetzt werden muss, wenn verschiedene Effekte auswählbar sein sollen. Und im drop-Event findet man den gesetzten Wert nicht mehr vor, dropEffect ist immer none. Es scheint, als wäre Chrome hier seit Jahren vorsätzlich fehlerhaft. Für unsere Demo ist der Fehler nicht von Belang, für andere Anwendungen muss man den Fehler irgendwie umgehen.

Der Drop-Handler für das Quiz sieht so aus:

Behandlung des drop Events
function handleDropEvent(dragEvent) {
   const dZone = getDropzone(dragEvent);
   const answerNumber = dropEvent.dataTransfer.getData(answerFormat);

   moveAnswer(answerNumber, dropZone);
   dropZone.classList.remove("dropping");

   setTimeout(checkCorrectness, 0);
   dropEvent.preventDefault();
}

Wie üblich steckt im Event-Handler selbst nicht viel drin, die Arbeit wird an andere Funktionen delegiert. Dies ist insbesondere in Hinsicht auf eine spätere, zusätzliche Tastaturbedienung wichtig. Denn wenn das Drag & Drop API von HTML 5 eins nicht mitbringt, dann eine automatische Tastaturbedienbarkeit.

Unser Drop-Handler erledigt trotz seiner Kompaktkeit eine Menge:

  • Ermitteln der Fragenummer, die gezogen wurde
  • Verschieben dieser Frage in die Dropzone
  • Entfernen der Hervorhebung der Dropzone
  • Anstoßen der Überprüfung auf Korrektheit. Dies mit setTimeout, damit vor der Prüfung das Ergebnis der Drop-Operation dargestellt wird. Falls die Korrektheitsprüfung einen Server kontaktiert (z.B. Ergebnis speichern), könnte es bei synchronem Ablauf eine ärgerliche visuelle Verzögerung geben.

Das eigentliche Verschieben des Elements muss unterschiedlich laufen, je nachdem, ob die Antwort neben eine Frage gezogen wird oder zurück in die Liste der offenen Antworten kommt. Das liegt am unterschiedlichen HTML Layout für diese beiden Teile, es wäre aber nicht gut, gegebene Antworten und offene Antworten in strukturell ähnliches HTML zu legen, nur um den Code zu vereinfachen. Die Semantik ist unterschiedlich, und damit auch das HTML.

Verschieben einer Antwort im DOM
function moveAnswer(answerNumber, dropZone) {
   const answer = document.querySelector(".answer[value='"+answerRef+"']");
   if (!dropTarget || !answer) return false;
   
   // Ablauf unterschiedlich, je nach Drop-Ziel 
   if (dropTarget == answers) {
       // Antwort zurück in die Antwortliste
       appendToAnswers(answer);
   } else {
      // Eltern-Element merken, aus dem die Antwort geholt wird   
      const answerContainer = answer.parentElement;
   
      // Antwort wird einer Frage zugeordnet: Es kann schon eine Antwort
      // darin stehen, diese muss zurück in die Antwortenzone
      const oldData = dropTarget.querySelector(".answer");
      if (oldData) {
         appendToAnswers(oldData);
      }
      dropTarget.appendChild(answer);
      // Wird eine Antwort aus der Antwortleiste herausgezogen,
      // das <li> Element entfernen, in der sie sich befand
      if (answers.contains(answerContainer)) {
         answerContainer.remove();
      }
   }
}

moveAnswer sucht aus dem DOM das Element heraus, das mit der Klasse "answer" markiert ist und dessen value-Attribut die Nummer der gesuchten Frage trägt. Existiert diese Antwort nicht, oder ist die Dropzone leer, wird die move-Operation abgebrochen.

Da für den Fall, dass die Antwort aus der Liste der offenen Antworten herausgezogen wird, ihr <li> Elternelement entfernt werden muss, wird zunächst das Elternelement der Antwort zwischengespeichert.

Dann folgt die Entscheidung: Ist das Ziel die Liste der offenen Antworten? Wenn ja, liegt bereits eine Funktion vor, um ein Antwort-Element dort aufzunehmen: appendToAnswers. Wenn nicht, wird es etwas komplizierter. Ein Antwortfeld neben einer Frage soll nur eine Antwort aufnehmen können (wobei man sich natürlcih auch Quiz vorstellen kann, die mehrere Antworten zu einer Frage vorsehen, aber diese Option soll hier außen vor bleiben). Die Funktion schaut also zunächst, ob das Dropziel ein Element mit Klasse answer enthält. Ist das der Fall, wird sie - wieder mittels appendToAnswers - zurück zu den offenen Antworten geschoben. Danach kann die per Drop abgelegte Antwort in das Antwortfeld aufgenommen werden.

Zum Abschluss wird geschaut, ob der Container, aus dem die Antwort herausgezogen wurde, in der Liste der offenen Antworten enthalten war. Wenn ja, ist es ein Listenelement und wird entfernt, um keine leeren <li> in der Liste zu behalten.

Nun muss nur noch geprüft werden, ob alle Antworten richtig gegeben wurde. Dazu ist die Funktion checkCorrectness da.

Verschieben einer Antwort im DOM
function checkCorrectness() {
   let answers = questions.querySelectorAll(".answer");
   if (answers.length != aufgabe.answers.length) {
      return;
   }
   if (Array.prototype.every.call(answers, 
            answer => answer.parentElement.dataset.ref == aufgabe.matches[answer.value])) {
      quiz.classList.add("correct");
   }
}

checkCorrectness sucht alle Antworten im Fragenbereich heraus und prüft zunächst, ob es genauso viele Antworten wie Fragen gibt. Wenn nicht, sind noch welche offen und Korrektheit kann nicht gegeben sein. Die nachfolgende Prüfung, ob alle Antworten richtig sind, verwendet einen in JavaScript häufig genutzten Trick, Array-Methoden auf arrayähnliche Gebilde anzuwenden: querySelectorAll liefert eine NodeList. Dies ist kein Array, aber die NodeList kennt eine length Eigenschaft und unterstützt den Array-Zugriffsoperator []. Das genügt für die every Methode, die auf Array.prototype zu finden ist. Mit Hilfe der call Methode, die jede Funktion besitzt, wird die Nodelist als this Argument an every übergeben. Hinzu kommt ein Callback, der pro Arrayelement aufzurufen ist. Dieser ist als Pfeilfunktion realsisiert und vergleicht den Wert im data-ref Attribut des dd-Elements - also die Fragenummer - mit dem Eintrag an der Position der Antwortnummer in der matches Tabelle der Aufgabenstellung. Sind die Werte gleich, steht die Antwort an der richtigen Stelle und die Callback-Funktion meldet true. Die every Methode meldet true, wenn die Callback-Funktion für jedes Element des Arrays true liefert. Ist das der Fall, wird dem Quiz die Klasse correkt hinzugefügt, so dass mit geeigneten Style-Regeln sichtbar gemacht werden kann, dass die Frage gelöst ist.

Was hier noch fehlt, ist eine weitere Behandlung. Soll ein Server benachrichtigt werden? Statistik geführt werden? Alles möglich, geht aber über Drag und Drop weit hinaus. Die Quiz-Demo tut darum einfach nichts und überlässt dem Anwender den Klick auf den Button für die nächste Aufgabe. Dieser Button ist immer verfügbar, damit man eine Aufgabe, auf deren Lösung man nicht kommt, auch überspringen kann.

Alles zusammen, aber noch nicht ganz

Was bisher gezeigt und diskutiert wurde, beinhaltet die reine Drag & Drop Steuerung. Ein Anwender, der keine Maus hat, kann das Quiz nicht bedienen. Darum soll nun noch eine einfache Hilfssteuerung per Tastatur hinzukommen.

Das HTML beinhaltet bereits ein <div> Element mit der ID assignPopup. In diesen Container sollen nun noch Buttons eingefügt werden, die dem Text der Fragen entsprechen. Die Stelle, an der das geschehen soll, ist in der init Methode bereits durch ein todo markiert. Dort soll nun ein Funktionsaufruf hinzukommen. Bei der Gelegenheit soll auch das andere todo erledigt werden: Das Löschen von Daten aus einer vorigen Aufgabe. Dazu wird das innerHTML der entsprechenden Container einfach geleert. Der bekannte Code der init Funktion wird durch ... angedeutet.

Vorbereiten der Tastaturbedienung
const quiz = ..., questions = ..., answers = ...,
      popup = document.getElementById("assignPopup"),

function init(aufg) {
   aufgabe = aufg;
   quiz.classList.remove("correct");
       
   // Alte Container leeren
   questions.innerHTML = "";
   answers.innerHTML = "";
   popup.innerHTML = "";
   ...
   aufgabe.questions.forEach... {
      ...
      popup.appendChild(createQuestionSelector(qText, i));
   }
   ...
   focusFirst(answers, ".answer");
   ...
   function createQuestionSelector(qText, refNr) {
      let selector = document.createElement("button");
      selector.innerHTML = qText;
      selector.value = refNr;
      return selector;
   }
}

Die focusFirst Funktion folgt gleich. Die Buttons für Antwortmöglichkeiten und Fragezuordnungen benötigen noch einen click-Handler.

Click-Handler für die Tastaturbedienung
function handleClickOnAnswerEvent(clickEvent) {
   const button = clickEvent.target.closest("button");
   if (!button) return;
   if (button.classList.contains("answer")) {
      const rect = button.getBoundingClientRect();
      popup.dataset.belongs = button.value;
      popup.classList.add("visible");
      popup.style.top = rect.bottom - 3 + "px";
      popup.style.left = rect.left + 3 + "px";
      focusFirst(popup, "button");
   }
   else if (popup.contains(button)) {
      const answerRef = popup.dataset.belongs;
      const dropRef = button.value;
      const dropCell = questions.querySelector(".answer-drop[data-ref='"+dropRef+"']");
      moveAnswer(answerRef, dropCell);
      popup.classList.remove("visible");

      setTimeout(checkCorrectness, 0);
      focusFirst(answers, ".answer"); 
   }
}

function focusFirst(container, selector) {
   const elem = container.querySelector(selector);
   if (elem) 
      elem.focus();
}

Der Klick-Handler enthält Code für zwei verschiedene Szenarien. Zum einen der Klick auf eine der Antworten, zum anderen der Klick auf einen Button zur Fragezuordnung. Beiden gemeinsam ist, dass sie auf Klicks in Buttons reagieren. Da HTML für Fragen zugelassen ist, muss der click-Handler zunächst den Button finden, weil ein Klick auf HTML Elemente innerhalb eines Buttons das Event-Target auf das HTML Element setzt, nicht auf den Button.

Wurde auf einen Button mit class="answer" geklickt, wird nun das Popup positioniert, sichtbar gemacht und der erste Button darin fokussiert. Damit das funktioniert, muss das Stylesheet das Popup auf position:absolute setzen


Das Drag & Drop Quiz ansehen …
<header>Selfhtml Drag &amp; Drop Quiz</header>
<main id="quiz">
  <h1>Aufgabe</h1>
  <p id="comment"></p>
  <dl id="questions"></dl>
  <ul id="answers" class="answer-drop"></ul>
  <div id="assignPopup"></div>
<button id="nextTask">Neue Aufgabe</button>
</main>