JavaScript/Tutorials/Eigene modale Dialogfenster

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Manchmal benötigt man in einer Webanwendung ein Dialogfenster, um Benutzereingaben für einen ganz speziellen Zweck einzuholen. In diesem Tutorial soll es darum gehen, wie man dieses auf zugängliche Art und mit größter gestalterischer Freiheit erreichen kann.

Siehe auch: HTML/Tutorials/dialog

Historische Altlasten

In JavaScript existieren die window-Methoden alert, confirm und prompt, um Benutzereingaben mit einer Dialog-Box einzufordern. Diese Methoden haben ihre historische Berechtigung, jedoch halten Sie jegliche weitere (eventuell auch im Hintergrund laufende) Funktionalität von JavaScript an und versetzen den Browser in einen besonderen Modus, was wiederum dazu führt, dass die eigentliche Seite nicht mehr erreicht werden kann, so lange das modale Fenster angezeigt wird.

Schauen wir uns einmal an, wie man diese drei Methoden so umsetzen kann, dass diese nicht nur frei gestaltbar werden, sondern auch die Ausführung von JavaScript im Hintergrund nicht weiter blockieren.

Nichtlinearer Programmverlauf

Wir müssen uns von dem sehr bequemen Gedanken lösen, dass wir an beliebiger Stelle einen Wert vom Benutzer einholen können. Klassisch würde man so denken:

linearer Programmablauf
function irgendwas () {
  var alter = 0, minderjaehrig = false;

  // Benutzer nach Alter fragen
  alter = prompt("Wie alt sind Sie?");

  if (alter < 18) {
    minderjaehrig = confirm("Du bist also noch nicht erwachsen?");
  }
}
Die Benutzereingaben werden im Programm an verschiedenen Stellen und manchmal nur unter gewissen Bedingungen benötigt. Dazu wird der Rückgabewert der entsprechenden window-Methode direkt genutzt. Die Variable alter enthält das eingegebene Alter (oder was auch immer der Benutzer gerade eingegeben hat!) und die Variable minderjaehrig entweder den Wert false oder true.

Wir müssen im Hinterkopf behalten, dass der Browser alles das, was er tun kann, sofort tut, um dann zu warten, bis er wieder etwas tun kann. Für Benutzereingaben bedeutet das, dass er warten muss, bis der Benutzer eine Eingabe tätigt. Wenn nun aber unser Programm nicht warten kann, da es sofort und gänzlich ausgeführt wird, müssen wir seine Struktur so anpassen, dass die Dinge, die erst zu einem späteren Zeitpunkt ausgeführt werden können, eben nicht sofort ausgeführt werden. Diese packen wir in eine Funktion, die den weiteren Programmablauf enthält und dann ausgeführt werden kann, wenn es soweit ist:

nichtlinearer Programmablauf ansehen …
function start () {
  var alter = 0, minderjaehrig = false;

  // hier werden wir nichtlinear
  frage("Wie alt sind Sie?", weiter); // weiter ist eine Funktion s.u.
}

function frage (text, callback, braucheBool) {
  // Benutzer etwas fragen
  var alter;

  if (braucheBool) {

    alter = confirm(text); // das werden wir ersetzen

  } else {

    alter = prompt(text); // das werden wir ersetzen
  }

  callback(alter); // hier geht es mit Ergebnis weiter
}

function weiter (alter) {
  if (alter < 18) {

    frage(
      "Du bist also noch nicht erwachsen?",
      // hier definieren wir die callback-Funktion direkt
      function (minderjaehrig) {
        ende(alter, minderjaehrig);
      },
      true // benutze confirm anstelle von prompt
    );

  } else {

    ende(alter, false);
  }
}

function ende (alter, minderjaehrig) {
  // wir haben alter und minderjaehrig abgefragt
  alert(
    "Alter: " + alter + " ("
    + (minderjaehrig ? "" : "aber nicht ")
    + "minderjährig)"
  );
}

// Programm starten
start();
Der Programmablauf ist in einzelne Teile aufgetrennt worden, die in eigenen Funktionen untergebracht sind. Das werden wir benötigen, wenn wir die window-Methoden mit anderen Techniken ersetzen.

Dialog-Boxen

Im Tutorial zugängliche Dialog-Box wird beschrieben, wie man sich das Dialog-Element für modale Fenster zunutze machen kann. Wir wollen die dort beschriebene Vorgehensweise dazu benutzen, um Vorlagen für Ersatzdialoge zu erstellen. Dabei verwenden wir Eingabeelemente wie <button> und <input>, jedoch ohne ein umgebendes Formular.

Vorlage für alert-Fenster

Das Ziel ist eine Dialog-Box mit einem OK-Button, sowie einem "Fenster schließen"-Button. Das könnte so aussehen:

Hinweisfenster
<dialog id="alert" role="dialog" aria-labelledby="alert-dialog-heading">
  <button class="close">Schließen</button>
  <h2 id="alert-dialog-heading">Info</h2>
  <p>Dieses Fenster können Sie bedenkenlos wieder schließen.</p>
  <p class="button-row">
    <button name="ok">OK</button>
  </p>
</dialog>

Vorlage für confirm-Fenster

Wenn es nur um das Einholen einer Bestätigung geht, benötigen wir zwei Buttons (klassischerweise "OK" und "Abbrechen"):

Bestätigungsfenster
<dialog id="confirm" role="dialog" aria-labelledby="confirm-dialog-heading">
  <button class="close">Schließen</button>
  <h2 id="confirm-dialog-heading">Bestätigung</h2>
  <p>Alles klar?</p>
  <p class="button-row">
    <button name="ok">OK</button>
    <button name="cancel">Abbrechen</button>
  </p>
</dialog>

Vorlage für prompt-Fenster

Zusätzlich zu "OK" und "Abbrechen" benötigen wir nun auch noch eine Eingabezeile:

Eingabefenster
<dialog id="prompt" role="dialog" aria-labelledby="prompt-dialog-heading">
  <button class="close">Schließen</button>
  <h2 id="confirm-dialog-heading">Eingabe</h2>
  <p>
    <label for="prompt-data">Was möchten Sie uns sagen?</label>
    <input id="prompt-data" name="data">
  </p>
  <p class="button-row">
    <button name="ok">OK</button>
    <button name="cancel">Abbrechen</button>
  </p>
</dialog>

Zusammenspiel von JavaScript und Dialog-Boxen

Jetzt, da wir schicke Dialog-Boxen haben, können wir die Methoden alert, confirm und prompt nachbilden. Dazu machen wir uns das Aufteilen des Programmcodes und die Nutzung von Callback-Funktionen zunutze.


window.myAlert

Unsere Dialog-Box für Hinweise benötigt eigentlich nur, dass sie angezeigt wird. Sollten im Programmverlauf aber mehrere solche Hinweise ausgegeben werden, würde jeder neue Hinweis den alten ersetzen, bevor der Benutzer das Fenster selbst geschlossen hat. Daher ist es sinnvoll, auch bei einem einfachen Hinweisfenster die Verwendung von Callback-Funktionen anzubieten, die ermöglichen, dass keine weiteren Programmteile ausgeführt werden, solange der Benutzer das Fenster nicht weggeklickt hat:

Ersatz für window.alert
window.myAlert = function (text, OK, cancel) {
  var dialog = document.querySelector("#alert"),
    textElement = document.querySelector("#alert [data-text]");

  if (dialog && textElement) {
    textElement.innerText = (text && text.length ? text : "");
    dialog.setCallback("cancel", cancel);
    dialog.setCallback("ok", OK);
    dialog.show();
  }
}
Unsere Funktion myAlert nimmt drei Parameter entgegen. Der erste (text) ist der anzuzeigende Hinweis, also ein String. Der zweite Parameter (OK) ist für die Callback-Funktion gedacht, die ausgeführt werden soll, wenn der Benutzer den OK-Button betätigt. Der dritte Parameter (cancel) ist für eine Funktion gedacht, die ausgeführt werden soll, wenn statt des OK-Buttons der Schließen-Button betätigt wird.
Die Zuordnung der Funktionen zu den Buttons regelt unsere Erweiterung im Polyfill, die für die Fälle "ok" und "cancel" schon alles vorbereitet hat. Wir brauchen die Funktionen hier nur entsprechend weiterzureichen.

window.myConfirm

Im Grunde funktioniert myConfirm exakt gleich wie myAlert. Es verwendet nur eine andere Vorlage, die eben auch einen Abbrechen-Button hat:

Ersatz für window.confirm
window.myConfirm = function (text, OK, cancel) {
  var dialog = document.querySelector("#confirm"),
    textElement = document.querySelector("#confirm [data-text]");

  if (dialog && textElement) {

    textElement.innerText = (text && text.length ? text : "");
    dialog.setCallback("cancel", cancel);
    dialog.setCallback("ok", OK);
    dialog.show();
  }
}

window.myPrompt

Die ursprüngliche prompt-Methode ermittelt einen String, den der Benutzer eingeben soll. Daher müssen wir den Wert des Eingabefelds bei der Callback-Funktion im "ok"-Falle mitgeben. Außerdem bietet window.prompt auch die Möglichkeit, einen Standardwert festzulegen, der schon vorbelegt wird, was wir ebenfalls unterstützen werden:

Ersatz für window.prompt
window.myPrompt = function (text, OK, cancel, defaultValue) {
  var dialog = document.querySelector("#prompt"),
    inputElement = document.querySelector('#prompt [name="data"]'),
    textElement = document.querySelector("#prompt [data-text]");

  if (dialog && textElement) {

    inputElement.value = (defaultValue && defaultValue.length ? defaultValue : "");
    textElement.innerText = (text && text.length ? text : "");
    dialog.setCallback("cancel", cancel);
    dialog.setCallback("ok", function () {
      OK(inputElement.value);
    });
    dialog.show();
  }
}
Die Callback-Funktion für den "ok"-Fall können wir nicht einfach so notieren, sondern müssen sie innerhalb einer (anonymen) Funktion aufrufen, um den Inhalt des <input>-Elements als Parameter mitgeben zu können.
Der vierte Parameter enthält bei Bedarf einen Wert, der im Eingabefeld vorbelegt werden soll. Enthält er nichts Brauchbares, schreibt myPrompt einen Leerstring hinein, was eventuell vorhandene vorherige Werte (von vielleicht vorherigen Aufrufen) wieder entfernt.

Fertiges Beispiel mit myAlert, myConfirm und myPrompt

Wenn man alle Teile zusammenfügt, kann man nun unsere neuen Möglichkeiten testen:

Fertiges Beispiel mit myAlert, myConfirm und myPrompt ansehen …
<section>
  <h2>Alert-Fenster</h2>
  <p><button data-js="alert">myAlert()</button></p>
</section>
<section>
  <h2>Confirm-Fenster</h2>
  <p>
    <button data-js="confirm">myConfirm()</button>
    Ergebnis:
    <output></output>
  </p>
</section>
<section>
  <h2>Prompt-Fenster</h2>
  <p>
    <button data-js="prompt">myPrompt()</button>
    Ergebnis:
    <output></output>
  </p>
</section>
// Demo: alle Buttons mit Funktionalität ausrüsten
Array.prototype.slice.call(
  document.querySelectorAll("section")
).forEach(function(section) {
  var button = section.querySelector("[data-js]"),
    output = section.querySelector("output");

  if (button) {

    button.addEventListener("click", function () {

      switch (button.dataset.js) {

        case "alert":
          myAlert("Dieses Fenster können Sie bedenkenlos wieder schließen.");
        break;

        case "confirm":
          myConfirm(
            "Sind Sie mit SELFHTML zufrieden?",
            function () {
              output.className = "ok";
              output.value = "ok";
            },
            function () {
              output.className = "false";
              output.value = "false";
            }
          );
        break;

        case "prompt":
          myPrompt(
            "Was möchten Sie uns sagen?",
            function (result) {
              output.className = "ok";
              output.value = '"' + result + '"';
            },
            function () {
              output.className = "false";
              output.value = "false";
            }
          );
        break;
      }
    });
  }
});
Mit den Buttons wird die entsprechende neue window-Methode ausgelöst, die in die jeweils vorbereiteten <output>-Elemente das Ergebnis hineinschreibt. Dabei wird berücksichtigt, ob der Rückgabewert ein boolesches false ist (wenn "Abbrechen" oder "Schließen" bedient wurde), oder ob der OK-Button bedient wurde, was bei myPrompt zu einer Zeichenkette und bei myConfirm zu einem true führt.

Das Anfangsbeispiel überarbeitet

In unserem früheren Beispiel verwendeten wir eine Ersatzfunktion frage, um den folgenden Programmablauf an eine weitere Funktion zu übergeben. Diese Funktion können wir uns nun sparen, da wir mit den Callback-Funktionen das gleiche erreichen:

Anfangsbeispiel mit dialog-Elementen ansehen …
// die nicht-lineare Programm-Logik
function start () {
  // alter erfragen
  window.myPrompt(
    "Wie alt sind Sie?",
    // Funktion für OK-Button
    weiter,
    // Funktion für Abbrechen-Button oder Schließen-Button
    weiter
  );
}

function weiter (alter) {
  if (alter < 18) {

    window.myConfirm(
      "Du bist also noch nicht erwachsen?",
      // Funktion für OK-Button
      function () {
        ende(alter, true);
      },
      // Funktion für Abbrechen-Button oder Schließen-Button
      function () {
        ende(alter);
      }
    );

  } else {

    ende(alter, false);
  }
}

function ende (alter, minderjaehrig) {
  window.myAlert(
    "Alter: " + alter + " ("
    + (minderjaehrig ? "" : "aber nicht ")
    + "minderjährig)"
  );
}

// Programm starten
start();

Individuelle Lösungen

Mit window.confirm und window.prompt kann man nur sehr allgemeine Nutzereingaben verarbeiten: Boolescher Wert (confirm) oder Zeichenkette (prompt). Wenn wir unsere neu erarbeiteten Alternativen von oben anschauen, sehen wir, dass der ganze Aufwand nicht nur den Vorteil der freien visuellen Gestaltung bietet, sondern auch Möglichkeiten, beliebig zu erweitern.

Das folgende Beispiel verwendet eine Ergänzung im Polyfill, die darauf prüft, ob bei der Verwendung der ENTER-Taste der Fokus sich vielleicht in einem Eingabe-Element befindet, was dann natürlich nicht zum Schließen des Dialogs (mit der OK-Button-Funktion) führen darf. Desweiteren verwendet das Beispiel eine recht aufgeblasene Funktion zum Erstellen eines Dialog-Fensters, das dafür aber je nach Bedarf individuell schon beim Aufruf konfiguriert werden kann:

komplexere Dialog-Box ansehen …
// neue window-Methode
window.myDialog = function (data, OK, cancel) {
  var dialog = document.querySelector("#my-dialog"),
    buttonRow = document.querySelector("#my-dialog .button-row"),
    heading = document.querySelector("#my-dialog-heading"),
    element, p, prop;

  if (dialog && buttonRow) {

    // Standard-Titel
    if (heading) {
      heading.textContent = "Eingabe";
    }

    // jedes <ul> und <p> entfernen, außer <p class="button-row">
    Array.prototype.slice.call(
      dialog.querySelectorAll("ul, p:not(.button-row)")
    ).forEach(function (p) {
      p.parentNode.removeChild(p);
    });

    // Elemente erstellen und gegebenenfalls mit Inhalten befüllen
    for (prop in data) {

      // alles bekommt ein <p> drumherum
      p = document.createElement("p");

      buttonRow.parentNode.insertBefore(p, buttonRow);

      // simple Textausgabe
      if (data[prop].type && data[prop].type == "info") {
        p.textContent = data[prop].text;
      }

      // anderer Titel
      if (data[prop].type && data[prop].type == "title"
        && heading
      ) {

        heading.textContent = data[prop].text;

        // neues <p> wird hierfür nicht benötigt
        p.parentNode.removeChild(p);
      }

      // numerischer Wert
      if (data[prop].type && data[prop].type == "number") {

        // <label> als Kindelement für Beschriftung
        p.appendChild(document.createElement("label"));
        p.lastChild.appendChild(
          document.createTextNode(data[prop].text + " ")
        );

        // <input type="number">
        element = p.appendChild(
          document.createElement("input")
        );

        if (data[prop].hasOwnProperty("max")) {
          element.max = data[prop]["max"];
        }

        if (data[prop].hasOwnProperty("min")) {
          element.min = data[prop]["min"];
        }

        if (data[prop].hasOwnProperty("step")) {
          element.step = data[prop]["step"];
        }

        element.name = prop;
        element.type = "number";
        element.value = element.min = data[prop]["min"] || 0;

        if (data[prop].default) {
          element.value = data[prop].default;
        }
      }

      // Mehrfachauswahl
      if (data[prop].type && data[prop].type == "multiple") {

        p.textContent = data[prop].text;

        // alle Optionen wandern in ein <ul>
        element = document.createElement("ul");
        buttonRow.parentNode.insertBefore(element, buttonRow);

        data[prop].options.forEach(function (d, index) {
          var input = document.createElement("input"),
            label = document.createElement("label"),
            li = document.createElement("li");

          // <li> in <ul> einhängen
          element.appendChild(li);

          input.id = prop + "-" + index;
          input.name = prop + "-" + index;
          input.type = "checkbox";
          input.value = d;
          li.appendChild(input);

          label.htmlFor = prop + "-" + index;
          label.textContent = " " + d
          li.appendChild(label);

          if (data[prop].default && data[prop].default == d) {
            input.setAttribute("checked", "checked");
          }
        });
      }

      // Einfachauswahl
      if (data[prop].type && data[prop].type == "select") {

        // <label> als Kindelement für Beschriftung
        p.appendChild(document.createElement("label"));
        p.lastChild.appendChild(
          document.createTextNode(data[prop].text + " ")
        );

        // alle Optionen wandern in ein <ul>
        element = p.appendChild(
          document.createElement("select")
        );

        element.name = prop;

        data[prop].options.forEach(function (d) {
          var o = document.createElement("option");

          o.textContent = d;
          o.value = d;

          element.appendChild(o);

          if (data[prop].default && data[prop].default == d) {
            o.setAttribute("selected", "selected");
          }
        });
      }

      // Texteingabe
      if (data[prop].type && data[prop].type == "text") {

        // <label> als Kindelement für Beschriftung
        p.appendChild(document.createElement("label"));
        p.lastChild.appendChild(
          document.createTextNode(data[prop].text)
        );

        // alle Optionen wandern in ein <ul>
        element = p.appendChild(
          document.createElement("textarea")
        );

        element.name = prop;

        if (data[prop].default) {
          element.textContent = data[prop].default;
        }
      }
    }

    dialog.setCallback("cancel", cancel);

    dialog.setCallback("ok", function () {
      var result = {},
        elements;

      // Ergebnisse ermitteln
      for (prop in data) {

        elements = Array.prototype.slice.call(
          dialog.querySelectorAll('[name^="' + prop + '"]')
        );

        if (data[prop].type && data[prop].type == "multiple") {

          result[prop] = [];

          elements.forEach(function (element) {

            if (element.checked) {
              result[prop].push(element.value);
            }
          });

        } else {

          if (data[prop].type != "title"
            && data[prop].type != "info"
          ) {

            result[prop] = null;

            if (elements[0]) {
              result[prop] = elements[0].value;
            }
          }
        }
      }

      // Ergebnisse an die Callback-Funktion zurück geben
      OK(result);
    });

    dialog.show();
  }
}
Mit einer for in-Schleife werden die in dem Funktionsparameter data enthaltenen Eingabedaten abgearbeitet. Dabei handelt es sich um einfache Objekte, die eine Eigenschaft text enthalten, welcher als eigentlicher Wert verstanden wird. Zusätzlich müssen sie eine Eigenschaft type enthalten, wonach dann die passenden Eingabelemente erzeugt werden. Um den Fenstertitel zu ändern gibt es extra den Typ title. Für eine reine Textzeile ohne weitere Eingabemöglichkeit gibt es den Typ info.

Da für jedes Eingabedatum eine neue Zeile benötigt wird, wird grundsätzlich ein neuer Textabsatz eingefügt, der vor dem Textabsatz mit den Buttons OK und Abbrechen zu liegen kommt.

Wenn unser Dialog erneut benötigt werden sollte, stehen die vorherigen Eingabe-Elemente natürlich noch da, also <p>- und <ul>-Elemente, welche bei der Vorbereitung des Dialogfensters natürlich erst entfernt werden.
// Aufruf mit selbst konfigurierten Eingabemöglichkeiten
myDialog(
  // data
  {
    instructions: {
      text: "Bitte seien Sie jetzt komplett ehrlich und füllen Sie wahrheitsgemäß alles aus!",
      type: "info"
    },
    title: {
      text: "Sonderabfrage",
      type: "title"
    },
    sex: {
      "default": "weiblich",
      options: ["männlich", "weiblich", "divers"],
      text: "Geschlecht",
      type: "select"
    },
    age: {
      "default": 18,
      "max": 150,
      "min": 0,
      step: 1,
      text: "Alter",
      type: "number"
    },
    preferences: {
      "default": "Pop",
      options: ["Jazz", "Swing", "Latin", "Klassik", "Hiphop", "Pop"],
      text: "Diese Musik mag ich gerne",
      type: "multiple"
    },
    message: {
      text: "Das will ich mitteilen",
      type: "text"
    }
  },
  // OK
  function (data) {
    var output = document.querySelector("main pre"),
      prop,
      result = "Ergebnis:\r\n=========\r\n\r\n";

    for (prop in data) {

      result += prop + ":";

      if (typeof data[prop] == "object") {

        data[prop].forEach(function (value, index) {
          result += (index ? "," : "") + "\r\n\t" + value;
        });

      } else {

        result += " " + data[prop];
      }

      result += "\r\n";
    }

    if (output) {
      output.textContent = result;
    }
  },
  // cancel
  function () {
    var output = document.querySelector("main pre");

    if (output) {
      output.textContent = "(kein Ergebnis)";
    }
  }
);
Der Funktionsaufruf erwartet als ersten Parameter ein Objekt, welches beliebig benannte Unterobjekte als seine Eigenschaften enthält. Die Namen dieser Unterobjekte werden im Dialogfenster dafür benutzt, die Eingabedaten wieder unter diesem Namen verfügbar zu machen. Außerdem sieht man, wie mögliche Vorbelegungen von Eingabefeldern durch eine default genannte Eigenschaft in den Unterobjekten genutzt werden kann.

Der Phantasie des Programmierers sind so keine Grenzen gesetzt. Auch ist die hier beschriebene Vorgehensweise nur eine von unendlich vielen denkbaren. Sie soll zeigen, dass man sich auf diese Art ein kleines Rahmenwerk (Framework) schaffen kann, das dann ziemlich flexibel beliebige Dialog-Boxen erzeugen und die darin eingetragenen Informationen wieder sinnvoll zurück liefern kann.

Die Sache mit dem Fokus

Unsere Beispiele haben keinen Mechanismus, der dafür sorgt, dass der Anwender den Fokus aus einem geöffneten Dialog nicht heraus bewegen kann, um die Bedienbarkeit rein mit der Tastatur unter allen Umständen zu gewährleisten. In diesem Tutorial beschränken wir uns lediglich darauf, den Fokus auf den Schließen-Button in der Dialog-Box zu setzen. Andere Lösungen, die wesentlich aufwändiger gearbeitet sind, treiben in dieser Hinsicht deutlich mehr Aufwand.

Es sei auch nicht verschwiegen, dass die Frage "wohin mit dem Fokus danach?" keine triviale Frage ist. Je nach Umständen möchte man den Fokus wieder dort haben, wo er vor der Anzeige der Dialog-Box war. Dann muss man dafür Sorge tragen, dass nach dem Schließen des Dialogs der Fokus auch wieder dort hin zurück kehrt. Aber in anderen Fällen möchte man das vielleicht gerade eben nicht. Es hängt also von der Gestaltung der Applikation ab, wie Benutzer hinsichtlich des Eingabefokus angeleitet werden sollen.

Siehe auch

Weblinks