Verwaltung des Zustands in CSS mit wiederverwendbaren JavaScript-Funktionen – Teil 2

Avatar of Luke Harrison
Luke Harrison am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

In meinem vorherigen Artikel, der nun rückwirkend als Verwaltung des Zustands in CSS mit wiederverwendbaren JavaScript-Funktionen – Teil 1 bekannt sein wird, haben wir eine leistungsstarke, wiederverwendbare Funktion erstellt, die es uns ermöglicht, über einen Klick zustandsbasierte Klassen schnell hinzuzufügen, zu entfernen und umzuschalten.

Einer der Gründe, warum ich diesen Ansatz teilen wollte, war, die Reaktion darauf zu sehen. Seitdem habe ich einige interessante Rückmeldungen von anderen Entwicklern erhalten, wobei einige berechtigte Mängel dieses Ansatzes hervorgehoben haben, die mir sonst nie in den Sinn gekommen wären.

In diesem Artikel werde ich einige Lösungen für diese Mängel präsentieren und weitere Funktionen sowie allgemeine Verbesserungen einbauen, um unsere wiederverwendbare Funktion noch leistungsfähiger zu machen.

Artikelserie

  1. Ursprünglicher Artikel
  2. Verwaltung des Zustands in CSS mit wiederverwendbaren JavaScript-Funktionen (Sie sind hier!)

Zur Referenz, hier ist das JavaScript aus Teil 1 für unsere wiederverwendbare Funktion, wie sie bisher besteht.

// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-class][data-class-element]");

// closestParent helper function
closestParent = function(child, match) {
  if (!child || child == document) {
    return null;
  }
  if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
    return child;
  }
  else {
    return closestParent(child.parentNode, match);
  }
}

// Loop through if any are found
for(var i = 0; i < elems.length; i++){
  // Add event listeners to each one
  elems[i].addEventListener("click", function(e){

    // Prevent default action of element
    e.preventDefault();

    // Grab classes list and convert to array
    var dataClass = this.getAttribute('data-class');
    dataClass = dataClass.split(", ");

    // Grab linked elements list and convert to array
    var dataClassElement = this.getAttribute('data-class-element');
    dataClassElement = dataClassElement.split(", ");

    // Grab data-class-behaviour list if present and convert to array
    if(this.getAttribute("data-class-behaviour")) {
      var dataClassBehaviour = this.getAttribute("data-class-behaviour");
      dataClassBehaviour = dataClassBehaviour.split(", ");
    }

    // Grab data-scope list if present and convert to array
    if(this.getAttribute("data-class-scope")) {
      var dataClassScope = this.getAttribute("data-class-scope");
      dataClassScope = dataClassScope.split(", ");
    }

    // Loop through all our dataClassElement items
    for(var b = 0; b < dataClassElement.length; b++) {
      // Grab elem references, apply scope if found
      if(dataClassScope && dataClassScope[b] !== "false") {
        // Grab parent
        var elemParent = closestParent(this, dataClassScope[b]),

        // Grab all matching child elements of parent
        elemRef = elemParent.querySelectorAll("." + dataClassElement[b]);

        // Convert to array
        elemRef = Array.prototype.slice.call(elemRef);

        // Add parent if it matches the data-class-element and fits within scope
        if(dataClassScope[b] === dataClassElement[b] && elemParent.classList.contains(dataClassElement[b])) {
          elemRef.unshift(elemParent);
        }
      }
      else {
        var elemRef = document.querySelectorAll("." + dataClassElement[b]);
      }
      // Grab class we will add
      var elemClass = dataClass[b];
      // Grab behaviour if any exists
      if(dataClassBehaviour) {
        var elemBehaviour = dataClassBehaviour[b];
      }
      // Do
      for(var c = 0; c < elemRef.length; c++) {
        if(elemBehaviour === "add") {
          if(!elemRef[c].classList.contains(elemClass)) {
            elemRef[c].classList.add(elemClass);
          }
        }
        else if(elemBehaviour === "remove") {
          if(elemRef[c].classList.contains(elemClass)) {
            elemRef[c].classList.remove(elemClass);
          }
        }
        else {
          elemRef[c].classList.toggle(elemClass);
        }
      }
    }

  });    
}

Von nun an wird dies die Grundlage für unsere Verbesserungen bilden.

Legen wir los!

Barrierefreiheit (Accessibility)

Das häufigste Feedback, das ich von anderen Entwicklern als Reaktion auf Teil 1 erhalten habe, war die mangelnde Berücksichtigung von Barrierefreiheit in diesem Ansatz. Genauer gesagt, die fehlende Unterstützung für ARIA-Attribute (oder ARIA-Zustände, je nach Vorliebe) und das Versäumnis, Tastaturereignisse zum Auslösen unserer wiederverwendbaren Funktion bereitzustellen.

Mal sehen, wie wir beides integrieren können.

ARIA-Attribute

ARIA-Attribute sind Teil der WAI-ARIA-Spezifikation. In den Worten der Spezifikation...

...werden sie verwendet, um plattformübergreifende Zugänglichkeits-APIs auf verschiedenen Betriebssystemplattformen zu unterstützen. Assistive Technologien können auf diese Informationen über eine exponierte User-Agent-DOM-Struktur oder durch eine Abbildung auf die Plattform-Zugänglichkeits-API zugreifen. In Kombination mit Rollen kann der User-Agent den assistiven Technologien Benutzerinformationen liefern, die dem Benutzer jederzeit vermittelt werden können. Änderungen an Zuständen oder Eigenschaften führen zu einer Benachrichtigung an assistive Technologien, die den Benutzer darauf aufmerksam machen könnten, dass eine Änderung stattgefunden hat.

Wenn wir das Akkordeon-Beispiel aus Teil 1 noch einmal betrachten, würde ein aria-expanded-Attribut, das auf true gesetzt ist, wenn die Komponente erweitert ist, und umgekehrt im Standardzustand, assistiven Technologien wie Screenreadern ermöglichen, die Komponente besser zu bewerten.

Zusätzlich zu diesen Vorteilen können wir, wie Ben Frain in seinem Artikel erläutert, zustandsbasierte Klassen weglassen und uns stattdessen auf ARIA-Attribute als unsere CSS-Hooks zum Stylen bestimmter Komponenten zustände verlassen.

Die Annahme dieses Ansatzes führt zu einer (peinlich berührten) „Win-Win“-Situation. Wir verbessern die Barrierefreiheit unserer Webanwendung und erhalten gleichzeitig ein klar definiertes, gut durchdachtes Vokabular zur Kommunikation der Zustände, die wir in unserer Anwendungslogik benötigen.

Zum Beispiel statt

.c-accordion.is-active .c-accordion__content {
  [...]
}

Hätten wir

.c-accordion[aria-expanded="true"] .c-accordion__content {
  [...]
}

Zurück zu unserer wiederverwendbaren Funktion: Wir werden Unterstützung dafür einbauen, damit das data-class-Attribut auch einen ARIA-Attribut-Verweis akzeptieren kann. Da wir nun Attribute statt nur Klassen manipulieren, wäre es semantisch sinnvoll, data-class und alle zugehörigen Attribute in data-state umzubenennen.

<div class="c-mycomponent" data-state="aria-expanded" data-state-element="c-mycomponent" aria-expanded="false" tabindex="0">

Im obigen Beispiel sollte das Klicken auf c-mycomponent aria-expanded auf sich selbst umschalten. Während im folgenden Beispiel zusätzlich zum vorherigen Verhalten my-class von c-myothercomponent entfernt würde.

<div class="c-mycomponent" data-state="aria-expanded, my-class" data-state-element="c-mycomponent, c-myothercomponent" data-state-behaviour="toggle, remove" aria-expanded="false" tabindex="0">

Zusätzlich zu aria-expanded sind weitere Beispiele, wie ARIA-Attribute anstelle von zustandsbasierten Klassen verwendet werden könnten:

  • aria-disabled="true" anstelle von is-disabled
  • aria-checked="true" anstelle von is-checked
  • aria-pressed="true" oder aria-selected="true" anstelle von is-active

Hier ist eine praktische ARIA-Cheat-Sheet, die während der Recherche für diesen Artikel nützlich war.

Implementierung

Unsere wiederverwendbare Funktion geht derzeit davon aus, dass alles, was ihr über unser neu benanntes data-state-Attribut übergeben wird, eine Klasse ist. Sie verhält sich dann entsprechend, basierend auf dem, was in data-state-behaviour definiert ist, oder ihrem Standard-toggle-Verhalten.

// Cycle through target elements
for(var c = 0; c < elemRef.length; c++) {
  if(elemBehaviour === "add") {
    if(!elemRef[c].classList.contains(elemClass)) {
      elemRef[c].classList.add(elemClass);
    }
  }
  else if(elemBehaviour === "remove") {
    if(elemRef[c].classList.contains(elemClass)) {
      elemRef[c].classList.remove(elemClass);
    }
  }
  else {
    elemRef[c].classList.toggle(elemClass);
  }
}

Lassen Sie uns dies leicht anpassen.

// Cycle through target elements
for(var c = 0; c < elemRef.length; c++) {
    // Find out if we're manipulating aria-attributes or classes
    var toggleAttr;
    if(elemRef[c].getAttribute(elemState)) {
        toggleAttr = true;
    }
    else {
        toggleAttr = false;
    }
    if(elemBehaviour === "add") {
        if(toggleAttr) {
            elemRef[c].setAttribute(elemState, true);
        }
        else {
            elemRef[c].classList.add(elemState);
        }
    }
    else if(elemBehaviour === "remove") {
        if(toggleAttr) {
            elemRef[c].setAttribute(elemState, false);
        }
        else {
            elemRef[c].classList.remove(elemState);
        }
    }
    else {
        if(toggleAttr) {
            if(elemRef[c].getAttribute(elemState) === "true") {
                elemRef[c].setAttribute(elemState, false);
            }
            else {
                elemRef[c].setAttribute(elemState, true);
            }
        }
        else {
            elemRef[c].classList.toggle(elemState);
        }
    }
}

Um ARIA-Attribute zu unterstützen, haben wir einfach eine Prüfung hinzugefügt, um zuerst zu sehen, ob das gegebene ARIA-Attribut auf dem Element existiert, und wenn nicht, wird davon ausgegangen, dass es sich um eine Klasse handelt und sie wird wie zuvor verarbeitet. Auf diese Weise können wir sowohl ARIA-Attribute als auch Klassen unterstützen, um alle Eventualitäten abzudecken. Entfernt wurden auch die classList.contains()-Prüfungen, da classList.add() und classList.remove() im aktuellen Standard intelligent genug sind, dies zu berücksichtigen.

Tastaturereignisse

Damit eine Website als barrierefrei gilt, ist es wichtig, dass sie einfach durch die alleinige Verwendung einer Tastatur navigiert und bedient werden kann. Aus Sicht des Entwicklers beinhaltet dies oft die Verwendung des tabindex-Attributs und die Nutzung von Tastaturereignissen.

In den meisten Browsern haben Elemente wie Anker bereits diese Eigenschaften standardmäßig. Sie können sie mit der Tabulatortaste anspringen und im Fokus aktivieren, indem Sie die Eingabetaste drücken. Für viele Komponenten, die aus einer Kombination von semantischen Elementen und Divs aufgebaut sind, ist dies jedoch nicht der Fall.

Lassen Sie uns unsere wiederverwendbare Funktion diese Lücke schließen lassen, indem wir Logik schreiben, um automatisch ein Tastaturereignis zum Trigger-Element hinzuzufügen, damit es – wie ein Anker – durch Drücken der Eingabetaste aktiviert werden kann.

Implementierung

Derzeit, da die Funktionslogik durch das Klicken auf ein Element mit den Attributen data-state und data-state-element ausgelöst wird, ist alles in einem click-Ereignis-Listener eingepackt.

elems[i].addEventListener("click", function(e){
  // Function logic
});

Da das Drücken der Eingabetaste die gleiche Funktionslogik wie ein Klick auslösen muss, ist es sinnvoll, diese Logik in eine eigene Funktion auszulagern, damit sie von beiden ausgelöst werden kann. Wir werden sie processChange() nennen.

// Assign click event
elem.addEventListener("click", function(e){
    // Prevent default action of element
    e.preventDefault(); 
    // Run state function
    processChange(this);
});
// Add keyboard event for enter key to mimic anchor functionality
elem.addEventListener("keypress", function(e){
    // e.which refers to the key pressed, 13 being the enter key.
    if(e.which === 13) {
        // Prevent default action of element
        e.preventDefault();
        // Run state function
        processChange(this);
    }
});

Zusätzlich zum vorhandenen click-Ereignis-Listener haben wir einen zusätzlichen Listener hinzugefügt, der auf das Drücken der Eingabetaste reagiert. Wenn ein übereinstimmendes keypress-Ereignis auf einem fokussierten Trigger-Element auftritt, reicht es aus, unsere neue processChange()-Funktion auszuführen und das Element zu übergeben.

Sie werden auch feststellen, dass keine Logik zum automatischen Hinzufügen eines tabIndex-Attributs vorhanden ist. Dies liegt daran, dass es mit einer bereits definierten tabIndex-Hierarchie auf der Seite kollidieren und die Absicht des Entwicklers beeinträchtigen könnte.

Beispiel

Hier ist eine modifizierte Version des Akkordeon-Beispiels aus Teil 1, aber vollständig aktualisiert, um ARIA-Attribute und Tastaturereignisse zu nutzen, um es zu einer zugänglicheren Komponente zu machen. Sie können die vollständige wiederverwendbare Funktion, wie sie jetzt besteht, im JavaScript-Panel sehen.

Siehe den Stift #7) Barrierefreiheitsbeispiel von Luke Harrison (@lukedidit) auf CodePen.

Berücksichtigung von nachträglich zum DOM hinzugefügten Elementen

In Teil 1 wurde im Kommentarbereich ein Problem angesprochen.

Ich glaube, das hätte einige Probleme für Elemente, die nachträglich zum DOM hinzugefügt werden. In diesem Fall müssten Sie das Zuweisen des Click-Events wiederholen. Habe ich Recht?

Das ist richtig! Alle Elemente mit den Attributen data-state und data-state-element, die nach dem anfänglichen Rendern des DOMs hinzugefügt werden, haben keine zugewiesenen Event-Listener. Wenn sie also angeklickt oder gewischt werden, passiert nichts.

Warum? Das liegt daran, dass in unserem JavaScript, sobald die anfängliche Zuweisung von Event-Listenern an Elemente mit data-state und data-state-element abgeschlossen ist, keine Funktionalität vorhanden ist, die sagt: „Hey! Achte auf neue Elemente mit den Attributen data-state und data-state-element und sorge dafür, dass sie funktionieren.“

Implementierung

Um dies zu beheben, werden wir etwas verwenden, das als MutationObserver bekannt ist. Obwohl sie viel besser in Davids Walchs großartigem Überblick über die API erklärt werden können, ermöglichen MutationObservers uns im Wesentlichen, alle Knoten zu verfolgen, die zum DOM hinzugefügt oder daraus entfernt werden (auch bekannt als „DOM-Mutationen“).

Wir können einen so einrichten.

// Setup mutation observer to track changes for matching elements added after initial DOM render
var observer = new MutationObserver(function(mutations) {
    mutations.forEach(function(mutation) {
        for(var d = 0; d < mutation.addedNodes.length; d++) {
            // Check if we're dealing with an element node
            if(typeof mutation.addedNodes[d].getAttribute === 'function') {
                if(mutation.addedNodes[d].getAttribute("data-state") && mutation.addedNodes[d].getAttribute("data-state-element")) {
                     // Create click and keyboard event listeners etc
                }
            }
        }
    });  
});

// Define type of change our observer will watch out for
observer.observe(document.body, {
  childList: true,
  subtree: true
});

Das macht unser MutationObserver.

  1. Erfasst alle DOM-Mutationen am Body-Element, die unmittelbare Kinder childList: true oder seine Nachkommen subtree: true sind.
  2. Prüft, ob diese DOM-Mutation ein neues Element-Knoten ist, anstatt eines Text-Knotens.
  3. Wenn ja, wird geprüft, ob der neue Element-Knoten die Attribute data-state und data-state-element hat.

Der nächste Schritt, vorausgesetzt, diese drei Prüfungen sind erfolgreich, wäre die Einrichtung unserer click- und keypress-Event-Listener. Wie bei der Implementierung von Tastaturereignissen, lagern wir diese Setup-Logik in eine eigene Funktion aus, damit wir sie sowohl beim Laden der Seite als auch bei Erkennung eines Elements mit den Attributen data-state und data-state-element durch unseren MutationObserver wiederverwenden können.

Wir werden diese neue Funktion initDataState() nennen.

// Init function
initDataState = function(elem){
  // Add event listeners to each one
  elems.addEventListener("click", function(e){
    // Prevent default action of element
    e.preventDefault();
    // Run state function
    processChange(this);
  });    
  // Add keyboard event for enter key to mimic anchor functionality
  elems.addEventListener("keypress", function(e){
      if(e.which === 13) {
          // Prevent default action of element
          e.preventDefault();
          // Run state function
          processChange(this);
      }
  });
}

Dann müssen wir nur noch alles richtig verknüpfen.

// Run when DOM has finished loading
document.addEventListener("DOMContentLoaded", function() {

  // Grab all elements with required attributes
  var elems = document.querySelectorAll("[data-state][data-state-element]");

  // Loop through if any are found
  for(var a = 0; a < elems.length; b++){
    initDataState(elems[a]);
  }

  // Setup mutation observer to track changes for matching elements added after initial DOM render
  var observer = new MutationObserver(function(mutations) {
      mutations.forEach(function(mutation) {
          for(var d = 0; d < mutation.addedNodes.length; d++) {
              // Check if we're dealing with an element node
              if(typeof mutation.addedNodes[d].getAttribute === 'function') {
                  if(mutation.addedNodes[d].getAttribute("data-state")) {
                       initDataState(mutation.addedNodes[d]);
                  }
              }
          }
      });  
  });

  // Define type of change our observer will watch out for
  observer.observe(document.body, {
      childList: true,
      subtree: true
  });
});

Beispiel

Klicken Sie auf die Schaltfläche „Hinzufügen“, um weitere Elemente auf der Seite einzufügen (Beispiel-Stift unten).

Siehe den Stift #8) Neue data-class-Elemente beim Hinzufügen zum DOM korrekt einrichten von Luke Harrison (@lukedidit) auf CodePen.

Wischgestenunterstützung

Derzeit verwendet unsere wiederverwendbare Funktion Klick- und Tastaturereignisse, um Zustandslogik auszulösen. Das ist auf Desktop-Ebene in Ordnung, aber auf Touch-Geräten ist es für einige UI-Komponenten (wie z. B. das Schließen eines ausklappbaren Navigationsmenüs) oft nützlicher, diese Logik beim Erkennen einer Wischgeste auszulösen.

Bauen wir optional eine Wischgestenunterstützung für unsere wiederverwendbare Funktion ein. Dies erfordert das Hinzufügen eines neuen Datenattributs, das unsere bestehenden ergänzt.

data-state-swipe

Der Zweck dieses neuen Attributs ist es, uns die Definition der Wischgestenrichtung zu ermöglichen, die unsere Zustandslogik auslösen soll. Diese Richtungen sollten sein:

  • hoch
  • right
  • runter
  • left

Lassen Sie uns auch die Option einbauen, um anzugeben, ob das Wischgestenereignis das Klickereignis ersetzen soll oder ob beide koexistieren sollen. Wir können ein Komma-getrenntes Boolean zu data-state-swipe hinzufügen, um dieses Verhalten auszulösen.

  • true – Der Wischgesten-Ereignis-Listener ersetzt den Klick-Ereignis-Listener
  • false – Sowohl der Wischgesten-Ereignis-Listener als auch der Klick-Ereignis-Listener werden hinzugefügt (Standard)

Zum Beispiel wird beim Erkennen einer Wischgeste nach links durch das div unten das Attribut aria-expanded auf js-elem auf true geändert. Der Wischgesten-Ereignis-Listener würde in diesem Fall auch den Klick-Ereignis-Listener ersetzen, da wir true in data-state-swipe übergeben.

<div data-state="aria-expanded" data-state-element="js-elem" data-state-swipe="left, true" data-state-behaviour="add">

Nun nehmen wir die Änderungen vor.

Implementierung

Die Wischgesten werden genauso verwaltet wie Klicks und Tastatureingaben – über Event-Listener. Um den Artikel auf unsere wiederverwendbare Funktion zu konzentrieren, verwende ich eine Hilfsfunktion namens swipeDetect(), die alle für die genaue Wischgestenerkennung erforderlichen Berechnungen übernimmt. Sie können jedoch gerne Ihre bevorzugte Methode zur Erkennung der Wischgestenrichtung anstelle davon verwenden.

Wir bauen Wischgesten in unsere wiederverwendbare Funktion ein, als weitere Möglichkeit, die Funktionslogik auszulösen. Daher ist es sinnvoll, dass sie mit den Klick- und Tastatur-Event-Listenern in initDateState() sitzt und dann processChange() auslöst, sobald unsere Anforderungen an eine gewünschte Wischgestenrichtung erfüllt sind.

Wir müssen jedoch auch das Verhaltens-Flag berücksichtigen, das in data-state-swipe übergeben wird und bestimmt, ob Wischgesten Klicks ersetzen sollen. Lassen Sie uns initDataState() umstrukturieren, um ein Gerüst hinzuzufügen, das all dies ordnungsgemäß unterstützt.

// Init function
initDataState = function(elem){
    // Detect data-swipe attribute before we do anything, as its optional
    // If not present, assign click event like before
    if(elem.getAttribute("data-state-swipe")){
        // Grab swipe specific data from data-state-swipe   
        var elemSwipe = elem.getAttribute("data-state-swipe"),
              elemSwipe = elemSwipe.split(", "),
              swipeDirection = elemSwipe[0],
              elemSwipeBool = elemSwipe[1],
              currentElem = elem;

        // If the behaviour flag is set to "false", or not set at all, then assign our click event
        if(elemSwipeBool === "false" || !elemSwipeBool) {
            // Assign click event
            elem.addEventListener("click", function(e){
                // Prevent default action of element
                e.preventDefault(); 
                // Run state function
                processChange(this);
            });
        }
        // Use our swipeDetect helper function to determine if the swipe direction matches our desired direction
        swipeDetect(elem, function(swipedir){
            if(swipedir === swipeDirection) {
                // Run state function
                processChange(currentElem);
            }
        })
    }
    else {
        // Assign click event
        elem.addEventListener("click", function(e){
            // Prevent default action of element
            e.preventDefault(); 
            // Run state function
            processChange(this);
        });
    }
    // Add keyboard event for enter key to mimic anchor functionality
    elem.addEventListener("keypress", function(e){
        if(e.which === 13) {
            // Prevent default action of element
            e.preventDefault();
            // Run state function
            processChange(this);
        }
    });
};

Diese Anpassungen an initDataState geben ihm nun 3 verschiedene Ergebnisse:

  1. Wenn ein data-state-swipe-Attribut auf dem Trigger-Element vorhanden ist und sein Verhaltens-Boolean auf true gesetzt ist, werden nur Wischgesten- und Tastaturereignisse zugewiesen.
  2. Wenn ein data-state-swipe-Attribut auf dem Trigger-Element vorhanden ist, aber sein Verhaltens-Boolean auf false gesetzt ist, werden Wischgesten-, Klick- und Tastaturereignisse zugewiesen.
  3. Wenn überhaupt **kein** data-state-swipe-Attribut auf dem Trigger-Element vorhanden ist, werden nur Klick- und Tastatur-Event-Listener zugewiesen.

Beispiel

Hier ist ein sehr einfaches Beispiel für die neue Wischgestenfunktionalität in der Praxis. Klicken Sie auf die Schaltfläche, um das Menü umzuschalten, und wischen Sie dann nach rechts über das Menü, wenn Sie sich auf einem Touch-Gerät befinden (oder in Ihrem bevorzugten Browser-Inspektor), um es zu schließen. Ganz einfach.

Siehe den Stift #9) Wischgestenunterstützung zu unserer wiederverwendbaren Funktion hinzufügen von Luke Harrison (@lukedidit) auf CodePen.

Funktionsverfeinerungen

Abschließend werden wir uns Wege ansehen, wie wir unsere wiederverwendbare Funktion verfeinern können, um sie effizienter und einfacher nutzbar zu machen.

Anvisieren des Trigger-Elements

Nehmen wir zum Beispiel ein Element namens c-btn, das beim Klicken aria-pressed auf sich selbst umschalten soll. Mit unserer wiederverwendbaren Funktion, wie sie derzeit besteht, würde das HTML etwa so aussehen:

<button class="c-btn" data-state="aria-pressed" data-state-element="c-btn" aria-pressed="false">

Das Problem hierbei ist, dass beim Klicken aria-pressed auf allen Instanzen von c-btn überall umgeschaltet wird, was nicht das gewünschte Verhalten ist.

Dies war das Problem, das data-state-scope zur Lösung geschaffen wurde. Indem wir unsere data-state-Instanz auf das nächstgelegene c-btn (was in diesem Fall es selbst wäre) beschränken, erstellen wir das gewünschte Umschaltverhalten.

<button class="c-btn" data-state="aria-pressed" data-state-element="c-btn" data-class-scope="c-btn" aria-pressed="false">

Obwohl der obige Ausschnitt gut funktioniert, ist es etwas störend, all diese Attribute zu haben, die alle auf dasselbe c-btn-Element verweisen. Idealerweise sollte die Funktion, wenn data-state-element und data-state-scope nicht definiert sind, standardmäßig das auslösende Element verwenden. Dies würde die einfache Anvisierung unseres Trigger-Elements ermöglichen. So:

<button class="c-btn" data-state="aria-pressed" aria-pressed="false">

Implementierung

data-scope-element ist derzeit ein erforderliches Attribut. Wenn es nicht vorhanden ist, kann die Funktion keine Event-Listener zuweisen. Das liegt daran, dass unser aktueller Scan des Dokuments in unserer wiederverwendbaren Funktion nach Elementen mit den Attributen data-scope **und** data-scope-element sucht.

// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state][data-state-element]");

Wir müssen dies anpassen, sodass wir nur nach Elementen mit data-state suchen, da data-state-element bald zu einem optionalen Attribut herabgestuft wird.

// Grab all elements with required attributes
var elems = document.querySelectorAll("[data-state]");

Zusätzlich müssen wir eine if-Anweisung zu processChange() hinzufügen, die die Abfrage des Werts von data-state-element umschließt, denn wenn er nicht vorhanden ist, wird die Funktion einen Fehler ausgeben, wenn sie versucht, getAttribute() auf etwas aufzurufen, das nicht existiert.

// Grab data-state-element list and convert to array
if(elem.getAttribute("data-state-element")) {
  var dataStateElement = elem.getAttribute("data-state-element");
  dataStateElement = dataStateElement.split(", ");
}

Als nächstes implementieren wir die Logik, die data-state-element und data-state-scope zum Trigger-Element standardmäßig festlegt, wenn sie nicht explizit definiert sind. Wir können auf unseren vorherigen Anpassungen von processChange() aufbauen und einen else-Block zu unserer data-state-element-Prüfung hinzufügen, um unser Ziel-Element und den Geltungsbereich manuell zu deklarieren.

// Grab data-state-element list and convert to array
// If data-state-element isn't found, pass self, set scope to self if none is present, essentially replicating "this"
if(elem.getAttribute("data-state-element")) {
  var dataStateElement = elem.getAttribute("data-state-element");
  dataStateElement = dataStateElement.split(", ");
}
else {
  var dataStateElement = [];
  dataStateElement.push(elem.classList[0]);
  if(!dataStateScope) {
    var dataStateScope = dataStateElement;
  }
}

Eine weitere Folge der Tatsache, dass data-state-element nicht mehr erforderlich ist, ist, dass seine Länge in processChange() in der for-Schleife verwendet wird, um sicherzustellen, dass alle in data-state-element definierten Elemente ihre Zustandsänderungen erhalten. Dies ist die Schleife, wie sie derzeit besteht:

// Loop through all our dataStateElement items
for(var b = 0; b < dataStateElement.length; b++) {
  [...]
}

Glücklicherweise müssen wir hier nur unser nun optionales data-state-element-Attribut durch unser immer noch erforderliches data-state-Element als Basis für diese Schleife ersetzen.

// Loop through all our dataStateElement items
for(var b = 0; b < dataState.length; b++) {
  [...]
}

Das liegt daran, dass in Fällen, in denen mehrere Werte jedem data-state-Attribut zugewiesen wurden (z. B.: <div data-state="is-active, is-disabled" data-state-element="my-elem, my-elem-2" data-state-behaviour="add, remove">) die Länge des daraus abgeleiteten Arrays immer übereinstimmen wird, sodass wir immer die gleiche Anzahl von Schleifen im for-Block erhalten.

Vereinfachung wiederholter Werte

Eine weitere Verbesserung, die wir vornehmen könnten, bezieht sich auf die Zuweisung ähnlicher Logikarten in einer einzigen data-state-Verwendung. Betrachten Sie das folgende Beispiel:

<a data-state="my-state, my-state-2, my-state-3" data-state-element="c-btn, c-btn, c-btn" data-state-behaviour="remove, remove, remove" data-state-scope="o-mycomponent, o-mycomponent, o-mycomponent">

Obwohl dies eine legitime Verwendung unserer wiederverwendbaren Funktion ist, werden Sie feststellen, dass wir viele wiederholte Werte in vielen der data-state-Attribute haben. Idealerweise, wenn wir ähnliche Arten von Logik mehrmals zuweisen möchten, sollten wir in der Lage sein, einen Wert nur einmal zu schreiben und die Funktion diesen als wiederholte Werte interpretieren zu lassen.

Zum Beispiel sollte der folgende HTML-Snippet die gleiche Aktion ausführen wie der obige.

<a data-state="my-state, my-state-2, my-state-3" data-state-element="c-btn" data-state-behaviour="remove" data-state-scope="o-mycomponent">

Hier ist ein weiteres Beispiel dafür, was als gültige data-state-Verwendung betrachtet werden sollte.

<a data-state="aria-expanded" data-state-element="c-menu, c-other-menu, c-final-menu" data-state-behaviour="remove, add">

Implementierung

Das erste, was wir berücksichtigen müssen, ist die for-Schleife in processChange(), die wir zuletzt im vorherigen Abschnitt geändert haben. Da sie die Länge von data-state als Basis für ihre Schleifenanzahl verwendet, würde die Implementierung dieser Änderungen zu einem Fehler führen, wenn wir eine Klasse auf viele Elemente anwenden.

Betrachten Sie Folgendes:

<a data-state="my-state" data-state-element="c-btn, c-btn-2" data-state-behaviour="remove" data-state-scope="o-mycomponent">

Was hier passieren würde, ist, dass, da data-state nur einen einzigen Wert hat, die for-Schleife in processChange() nur einmal durchlaufen würde, was bedeutet, dass unsere beabsichtigte Logik für c-btn-2 niemals zugewiesen würde.

Um dies zu beheben, müssen wir data-state und data-state-element vergleichen. Welches auch immer die meisten Werte hat, wird dann zur Basis für unsere Schleife. So:

// Find out which has the biggest length between states and elements and use that length as loop number
// This is to make sure situations where we have one data-state-element value and many data-state values are correctly setup
var dataLength = Math.max(dataStateElement.length, dataState.length);

// Loop
for(var b = 0; b < dataLength; b++) {
  [...]
}

Was den Rest der Implementierung angeht, so ist es nun eine Frage des Hinzufügens von Logik in der for-Schleife für jedes Attribut, das besagt: „Wenn kein übereinstimmender Wert gefunden werden kann, verwende den letzten gültigen.“

Lassen Sie uns den data-state-Wert als Beispiel nehmen. Derzeit sieht der Code in der for-Schleife, der den Zustands-Wert abruft, so aus:

// Grab state we will add
var elemState = dataState[b];

Das Problem ist nun, wenn wir 3 data-state-element-Werte haben, aber nur 1 data-state-Wert, würde bei den Schleifen 2 und 3 elemState undefined sein.

Was wir tun müssen, ist, elemState nur dann neu zu definieren, wenn wir einen Wert dafür haben. So:

// Grab state we will add
// If one isn't found, keep last valid one
if(dataState[b] !== undefined) {
  var elemState = dataState[b];
}

Dies würde sicherstellen, dass elemState immer einen Wert hat, einschließlich der Übernahme früherer Werte, wenn einer anfangs nicht gefunden werden kann.

Beispiel

Hier ist ein letztes Beispiel, das all unsere Funktionsverfeinerungen zeigt.

Siehe den Stift #10) Einfacheres Anvisieren von sich selbst & allgemeine Verbesserungen von Luke Harrison (@lukedidit) auf CodePen.

Abschluss

In diesem Artikel haben wir behandelt, wie man die in Teil 1 erstellte wiederverwendbare Funktion erweitern kann, um sie zugänglicher und einfacher nutzbar zu machen.

Zusätzlich haben wir auch eine Wischgestenunterstützung für Trigger-Elemente hinzugefügt und sichergestellt, dass alle data-state-Elemente, die nach der anfänglichen DOM-Darstellung hinzugefügt werden, nicht mehr ignoriert werden.

Wie zuvor sind Kommentare oder konstruktives Feedback willkommen. Ich überlasse Ihnen die vollständige wiederverwendbare Funktion, die wir in den letzten beiden Artikeln entwickelt haben.

(function(){

  // SWIPE DETECT HELPER
  //----------------------------------------------

  var swipeDetect = function(el, callback){ 
    var touchsurface = el,
    swipedir,
    startX,
    startY,
    dist,
    distX,
    distY,
    threshold = 100, //required min distance traveled to be considered swipe
    restraint = 100, // maximum distance allowed at the same time in perpendicular direction
    allowedTime = 300, // maximum time allowed to travel that distance
    elapsedTime,
    startTime,
    eventObj,
    handleswipe = callback || function(swipedir, eventObj){}

    touchsurface.addEventListener('touchstart', function(e){
      var touchobj = e.changedTouches[0]
      swipedir = 'none'
      dist = 0
      startX = touchobj.pageX
      startY = touchobj.pageY
      startTime = new Date().getTime() // record time when finger first makes contact with surface
      eventObj = e;
    }, false)

    touchsurface.addEventListener('touchend', function(e){
      var touchobj = e.changedTouches[0]
      distX = touchobj.pageX - startX // get horizontal dist traveled by finger while in contact with surface
      distY = touchobj.pageY - startY // get vertical dist traveled by finger while in contact with surface
      elapsedTime = new Date().getTime() - startTime // get time elapsed
      if (elapsedTime <= allowedTime){ // first condition for awipe met
        if (Math.abs(distX) >= threshold && Math.abs(distY) <= restraint){ // 2nd condition for horizontal swipe met
          swipedir = (distX < 0)? 'left' : 'right' // if dist traveled is negative, it indicates left swipe
        }
        else if (Math.abs(distY) >= threshold && Math.abs(distX) <= restraint){ // 2nd condition for vertical swipe met
          swipedir = (distY < 0)? 'up' : 'down' // if dist traveled is negative, it indicates up swipe
        }
      }
      handleswipe(swipedir, eventObj)
    }, false)
  }


  // CLOSEST PARENT HELPER FUNCTION
  //----------------------------------------------

  closestParent = function(child, match) {
    if (!child || child == document) {
      return null;
    }
    if (child.classList.contains(match) || child.nodeName.toLowerCase() == match) {
      return child;
    }
    else {
      return closestParent(child.parentNode, match);
    }
  }


  // REUSABLE FUNCTION
  //----------------------------------------------

  // Change function
  processChange = function(elem){

    // Grab data-state list and convert to array
    var dataState = elem.getAttribute("data-state");
    dataState = dataState.split(", ");

    // Grab data-state-behaviour list if present and convert to array
    if(elem.getAttribute("data-state-behaviour")) {
      var dataStateBehaviour = elem.getAttribute("data-state-behaviour");
      dataStateBehaviour = dataStateBehaviour.split(", ");
    }

    // Grab data-scope list if present and convert to array
    if(elem.getAttribute("data-state-scope")) {
      var dataStateScope = elem.getAttribute("data-state-scope");
      dataStateScope = dataStateScope.split(", ");
    }

    // Grab data-state-element list and convert to array
    // If data-state-element isn't found, pass self, set scope to self if none is present, essentially replicating "this"
    if(elem.getAttribute("data-state-element")) {
      var dataStateElement = elem.getAttribute("data-state-element");
      dataStateElement = dataStateElement.split(", ");
    }
    else {
      var dataStateElement = [];
      dataStateElement.push(elem.classList[0]);
      if(!dataStateScope) {
        var dataStateScope = dataStateElement;
      }
    }

    // Find out which has the biggest length between states and elements and use that length as loop number
    // This is to make sure situations where we have one data-state-element value and many data-state values are correctly setup
    var dataLength = Math.max(dataStateElement.length, dataState.length);

    // Loop
    for(var b = 0; b < dataLength; b++) {

      // If a data-state-element value isn't found, use last valid one
      if(dataStateElement[b] !== undefined) {
        var dataStateElementValue = dataStateElement[b];
      } 

      // If scope isn't found, use last valid one
      if(dataStateScope && dataStateScope[b] !== undefined) {
        var cachedScope = dataStateScope[b];
      }
      else if(cachedScope) {
        dataStateScope[b] = cachedScope;
      }

      // Grab elem references, apply scope if found
      if(dataStateScope && dataStateScope[b] !== "false") {

        // Grab parent
        var elemParent = closestParent(elem, dataStateScope[b]);

        // Grab all matching child elements of parent
        var elemRef = elemParent.querySelectorAll("." + dataStateElementValue);

        // Convert to array
        elemRef = Array.prototype.slice.call(elemRef);

        // Add parent if it matches the data-state-element and fits within scope
        if(elemParent.classList.contains(dataStateElementValue)) {
          elemRef.unshift(elemParent);
        }
      }
      else {
        var elemRef = document.querySelectorAll("." + dataStateElementValue);
      }
      // Grab state we will add
      // If one isn't found, keep last valid one
      if(dataState[b] !== undefined) {
        var elemState = dataState[b];
      }   
      // Grab behaviour if any exists
      // If one isn't found, keep last valid one
      if(dataStateBehaviour) {
        if(dataStateBehaviour[b] !== undefined) {
          var elemBehaviour = dataStateBehaviour[b];
        }
      }
      // Do
      for(var c = 0; c < elemRef.length; c++) {
        // Find out if we're manipulating aria-attributes or classes
        var toggleAttr;
        if(elemRef[c].getAttribute(elemState)) {
          toggleAttr = true;
        }
        else {
          toggleAttr = false;
        }
        if(elemBehaviour === "add") {
          if(toggleAttr) {
            elemRef[c].setAttribute(elemState, true);
          }
          else {
            elemRef[c].classList.add(elemState);
          }
        }
        else if(elemBehaviour === "remove") {
          if(toggleAttr) {
            elemRef[c].setAttribute(elemState, false);
          }
          else {
            elemRef[c].classList.remove(elemState);
          }
        }
        else {
          if(toggleAttr) {
            if(elemRef[c].getAttribute(elemState) === "true") {
              elemRef[c].setAttribute(elemState, false);
            }
            else {
              elemRef[c].setAttribute(elemState, true);
            }
          }
          else {
            elemRef[c].classList.toggle(elemState);
          }
        }
      }

    }

  },
    // Init function
    initDataState = function(elem){
    // Detect data-swipe attribute before we do anything, as its optional
    // If not present, assign click event like before
    if(elem.getAttribute("data-state-swipe")){
      // Grab swipe specific data from data-state-swipe
      var elemSwipe = elem.getAttribute("data-state-swipe"),
          elemSwipe = elemSwipe.split(", "),
          direction = elemSwipe[0],
          elemSwipeBool = elemSwipe[1],
          currentElem = elem;

      // If the behaviour flag is set to "false", or not set at all, then assign our click event
      if(elemSwipeBool === "false" || !elemSwipeBool) {
        // Assign click event
        elem.addEventListener("click", function(e){
          // Prevent default action of element
          e.preventDefault(); 
          // Run state function
          processChange(this);
        });
      }
      // Use our swipeDetect helper function to determine if the swipe direction matches our desired direction
      swipeDetect(elem, function(swipedir){
        if(swipedir === direction) {
          // Run state function
          processChange(currentElem);
        }
      })
    }
    else {
      // Assign click event
      elem.addEventListener("click", function(e){
        // Prevent default action of element
        e.preventDefault(); 
        // Run state function
        processChange(this);
      });
    }
    // Add keyboard event for enter key to mimic anchor functionality
    elem.addEventListener("keypress", function(e){
      if(e.which === 13) {
        // Prevent default action of element
        e.preventDefault();
        // Run state function
        processChange(this);
      }
    });
  };

  // Run when DOM has finished loading
  document.addEventListener("DOMContentLoaded", function() {

    // Grab all elements with required attributes
    var elems = document.querySelectorAll("[data-state]");

    // Loop through our matches and add click events
    for(var a = 0; a < elems.length; a++){
      initDataState(elems[a]);
    }

    // Setup mutation observer to track changes for matching elements added after initial DOM render
    var observer = new MutationObserver(function(mutations) {
      mutations.forEach(function(mutation) {
        for(var d = 0; d < mutation.addedNodes.length; d++) {
          // Check if we're dealing with an element node
          if(typeof mutation.addedNodes[d].getAttribute === 'function') {
            if(mutation.addedNodes[d].getAttribute("data-state")) {
              initDataState(mutation.addedNodes[d]);
            }
          }
        }
      });    
    });

    // Define type of change our observer will watch out for
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  });
}());

Artikelserie

  1. Ursprünglicher Artikel
  2. Verwaltung des Zustands in CSS mit wiederverwendbaren JavaScript-Funktionen (Sie sind hier!)