Verwaltung von Zuständen in CSS mit wiederverwendbaren JavaScript Funktionen

Avatar of Luke Harrison
Luke Harrison am

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

Die Ermittlung des effizientesten Wegs zur Verwaltung von Zuständen kann eine Herausforderung in CSS darstellen, aber glücklicherweise gibt es viele OOCSS-basierte Methodologien, die gute Lösungen bieten.

Artikelserie

  1. Verwaltung von Zuständen in CSS mit wiederverwendbaren JavaScript-Funktionen (Sie sind hier!)
  2. Aufgreifen der Ideen in diesem Artikel

Mein Favorit stammt von SMACSS (Scalable and modular architecture for CSS) und beinhaltet zustandsbezogene Klassen. Um die eigene Dokumentation von SMACSS zu zitieren, sind zustandsbezogene Klassen

Ein Zustand ist etwas, das alle anderen Stile ergänzt und überschreibt. Zum Beispiel kann ein Akkordeonabschnitt in einem eingeklappten oder ausgeklappten Zustand sein. Eine Nachricht kann einen Erfolgs- oder Fehlerzustand haben.

Zustände werden im Allgemeinen auf dasselbe Element wie eine Layoutregel oder auf dasselbe Element wie eine Basismodulklasse angewendet.

Eine meiner am häufigsten verwendeten zustandsbezogenen Klassen ist is-active. Am Beispiel des Akkordeons aus dem vorherigen Zitat würde is-active in diesem Fall alle erforderlichen CSS-Stile anwenden, um einen erweiterten Zustand darzustellen. Wie im folgenden Beispiel zu sehen ist

Siehe den Stift #1) Akkordeon-Komponente mit zustandsbezogener Klasse von Luke Harrison (@lukedidit) auf CodePen.

Sie werden feststellen, dass es etwas JavaScript gibt, das die Klasse is-active beim Erkennen eines Klickereignisses auf der Komponente umschaltet

var accordion = document.querySelectorAll(".c-accordion");

for(var i = 0; i < accordion.length; i++) {
    var accordionHeader = accordion[i].querySelector(".c-accordion__header"),
    accordionCurrent = accordion[i];

    accordionHeader.addEventListener("click", function(){
        accordionCurrent.classList.toggle("is-active");
    });
}

Obwohl es sich um gültiges JavaScript handelt, müsste dies für jede andere Komponente, die die zustandsbezogene Klasse is-active über ein Klickereignis nutzt, immer wieder wiederholt werden, was zu vielen Duplikaten desselben Code-Snippets führt.

Nicht sehr effizient und definitiv nicht sehr DRY.

Ein besserer Ansatz wäre stattdessen, eine einzige Funktion zu schreiben, die dieselbe Aufgabe erfüllt und immer wieder mit verschiedenen Komponenten wiederverwendet werden kann. Machen wir das.

Erstellen einer einfachen wiederverwendbaren Funktion

Beginnen wir mit dem Erstellen einer einfachen Funktion, die ein Element als Parameter akzeptiert und is-active umschaltet

var makeActive = function(elem){
    elem.classList.toggle("is-active");
}

Das funktioniert gut, aber wenn wir es in unser Akkordeon-JavaScript einfügen, gibt es ein Problem

var accordion = document.querySelectorAll(".c-accordion"),
makeActive = function(elem){
    elem.classList.toggle("is-active");
}

for(var i = 0; i < accordion.length; i++) {
    var accordionHeader = accordion[i].querySelector(".c-accordion__header"),
    accordionCurrent = accordion[i];

    accordionHeader.addEventListener("click", function(){
        makeActive(accordionCurrent);
    });
}

Obwohl die Funktion makeActive wiederverwendbar ist, müssen wir immer noch zuerst Code schreiben, um unsere Komponente und ihre inneren Elemente abzurufen. Es gibt also sicherlich viel Raum für Verbesserungen.

Um diese Verbesserungen vorzunehmen, können wir HTML5 benutzerdefinierte Datenattribute nutzen

<div class="c-accordion js-accordion">
    <div class="c-accordion__header" data-active="js-accordion">My Accordion Component</div>
    <div class="c-accordion__content-wrapper">
        <div class="c-accordion__content">
            Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce laoreet ultricies risus, sit amet congue nulla mollis et. Suspendisse bibendum eros sed sem facilisis ornare. Donec sit amet erat vel dui semper pretium facilisis eget nisi. Fusce consectetur vehicula libero vitae faucibus. Nullam sed orci leo. Fusce dapibus est velit, at maximus turpis iaculis in. Pellentesque ultricies ultrices nisl, eu consequat est molestie sit amet. Phasellus laoreet magna felis, ut vulputate justo tempor eu. Nam commodo aliquam vulputate.
        </div>
    </div>
</div>

Ein data-active-Attribut wurde dem Element hinzugefügt, das zuvor das is-active-Umschalten beim Klicken ausgelöst hat. Der Wert dieses Attributs repräsentiert das Element, auf dem das is-active-Umschalten stattfinden soll, was wie zuvor das übergeordnete c-accordion-Element ist. Beachten Sie die Hinzufügung einer neuen Klasse js-accordion anstelle des Hookings in die bestehende Klasse c-accordion. Dies dient dazu, funktionale Aspekte der Komponente von ihrer Gestaltung zu entkoppeln.

Werfen wir einen Blick auf das JavaScript

// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-active]");

// 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 linked elements
        var linkedElement = document.querySelectorAll("." + this.getAttribute("data-active"));

        // Toggle linked element if present
        for(var i = 0; i < linkedElement.length; i++) {
            linkedElement[i].classList.toggle("is-active");
        }

    });    
}

Dies hat die Dinge sicherlich verbessert, da wir keinen Code mehr schreiben müssen, um Elemente abzurufen, sondern nur ein data-active-Attribut an unser Trigger-Element anhängen und ein Ziel-Element angeben müssen. Derzeit kann diese Funktion für jede andere Komponente verwendet werden, bei der eine klickbasierte is-active-Klasse ohne zusätzlichen Code erforderlich ist. Vollständiges Beispiel unten

Siehe den Stift #2) Akkordeon-Komponente mit wiederverwendbarer is-active Funktion von Luke Harrison (@lukedidit) auf CodePen.

Verbesserung unserer wiederverwendbaren Funktion

Diese wiederverwendbare Funktion funktioniert, aber wenn sie skaliert wird, müssen wir darauf achten, dass sich die Klassen von Trigger- und Zielelementen nicht gegenseitig beeinflussen. Im folgenden Beispiel würde das Klicken auf ein Akkordeon is-active auf allen auslösen.

<div class="c-accordion js-accordion">
    <div class="c-accordion__header" data-active="js-accordion">First Accordion</div>
    [...]
</div>

<div class="c-accordion js-accordion">
    <div class="c-accordion__header" data-active="js-accordion">Second Accordion</div>
    [...]
</div>

<div class="c-accordion js-accordion">
    <div class="c-accordion__header" data-active="js-accordion">Third Accordion</div>
    [...]
</div>

Das Hinzufügen von Nummern-Suffixen zu jeder js-accordion-Referenz löst das Problem, aber es ist eine Mühe, die wir vermeiden können. Eine gute Lösung wäre, stattdessen die Kapselung in unserer wiederverwendbaren Funktion zu implementieren, die es uns ermöglicht, unsere Toggles zu kapseln, sodass sie nur die gewünschten Elemente beeinflussen.

Um die Kapselung zu implementieren, müssen wir ein separates benutzerdefiniertes Attribut namens data-active-scope erstellen. Sein Wert sollte das übergeordnete Element darstellen, innerhalb dessen das Umschalten gekapselt werden soll, was in diesem Fall das übergeordnete js-accordion-Element ist.

<div class="c-accordion js-accordion">
    <div class="c-accordion__header" data-active="js-accordion" data-active-scope="js-accordion">First Accordion</div>
    [...]
</div>

<div class="c-accordion js-accordion">
    <div class="c-accordion__header" data-active="js-accordion">Second Accordion</div>
    [...]
</div>

Unter Verwendung des obigen HTML sollte das folgende Verhalten eintreten

  1. Wenn Sie das erste Akkordeon anklicken, weil es einen Bereich js-accordion hat, werden nur data-active-Elemente, die mit dieser js-accordion-Instanz übereinstimmen oder deren Kinder sind, is-active umschalten.
  2. Wenn Sie das zweite Akkordeon anklicken, das keinen Bereich hat, wird is-active auf allen Instanzen von js-accordion umgeschaltet.

Vorausgesetzt, data-active-scope ist korrekt gesetzt, sollten alle Klassenumschaltungen innerhalb jedes js-accordion-Elements gekapselt werden, unabhängig von widersprüchlichen Klassennamen.

Hier ist das modifizierte JavaScript und ein funktionierendes Beispiel, das Akkordeons mit und ohne data-active-scope-Attribut zeigt

// Grab all elements with data-active attribute
var elems = document.querySelectorAll("[data-active]"),
// 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 scope if defined
        if(this.getAttribute("data-active-scope")) {
            var scopeElement = closestParent(this, this.getAttribute("data-active-scope"));
        }

        if(scopeElement) {
            // Grab scoped linked element
            var linkedElement = scopeElement.querySelectorAll("." + this.getAttribute("data-active"));
            // Convert to array
            linkedElement = Array.prototype.slice.call(linkedElement);
            // Check if our scope matches our target element and add to array if true.
            // This is to make sure everything works when data-active matches data-active-scope.
            if(scopeElement.classList.contains(this.getAttribute("data-active"))) {
                linkedElement.unshift(scopeElement);
            }
        }
        else {
            // Grab linked element
            var linkedElement = document.querySelectorAll("." + this.getAttribute("data-active"));
        }

        // Toggle linked element if present
        for(var i = 0; i < linkedElement.length; i++) {
            linkedElement[i].classList.toggle("is-active");
        }

    });    
}

Siehe den Stift #3) Akkordeon-Komponente mit verbesserter wiederverwendbarer is-active Funktion von Luke Harrison (@lukedidit) auf CodePen.

Mehr als nur is-active

Unsere wiederverwendbare Funktion funktioniert jetzt gut und ist eine effiziente Methode, um is-active-Umschaltungen auf allen Arten von Komponenten einzurichten. Was aber, wenn wir eine ähnliche Umschaltung für eine andere zustandsbezogene Klasse einrichten müssen? Derzeit müssten wir die Funktion duplizieren und alle Verweise auf is-active auf die neue zustandsbezogene Klasse ändern. Nicht sehr effizient.

Wir sollten unsere wiederverwendbare Funktion verbessern, um jede Klasse durch Refactoring unserer Datenattribute zu akzeptieren. Anstatt das data-active-Attribut an unser Trigger-Element anzuhängen, ersetzen wir es durch Folgendes

  • data-class – Die Klasse, die wir hinzufügen möchten.
  • data-class-element – Das Element, zu dem wir die Klasse hinzufügen möchten.
  • data-class-scope – Das Bereichsattribut erfüllt dieselbe Funktion, wurde aber zur Konsistenz umbenannt.

Dies erfordert einige geringfügige Anpassungen an unserem JavaScript

// Grab all elements with data-active attribute
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 scope if defined
        if(this.getAttribute("data-class-scope")) {
            var scopeElement = closestParent(this, this.getAttribute("data-class-scope"));
        }

        if(scopeElement) {
            // Grab scoped linked element
            var linkedElement = scopeElement.querySelectorAll("." + this.getAttribute("data-class-element"));
            // Convert to array
            linkedElement = Array.prototype.slice.call(linkedElement);
            // Check if our scope matches our target element and add to array if true.
            // This is to make sure everything works when data-active matches data-active-scope.
            if(scopeElement.classList.contains(this.getAttribute("data-class-element"))) {
                linkedElement.unshift(scopeElement);
            }
        }
        else {
            // Grab linked element
            var linkedElement = document.querySelectorAll("." + this.getAttribute("data-class-element"));
        }

        // Toggle linked element if present
        for(var i = 0; i < linkedElement.length; i++) {
            linkedElement[i].classList.toggle(this.getAttribute("data-class"));
        }           

    });    
}

Es würde im HTML wie folgt eingerichtet werden

<button class="c-button" data-class="is-loading" data-class-element="js-form-area">Submit</button>

Im folgenden Beispiel schaltet das Anklicken der c-button-Komponente die Klasse is-loading auf der js-form-area-Komponente um

Siehe den Stift #4) Formular-Komponente mit verbesserter wiederverwendbarer Funktion für jede Klasse von Luke Harrison (@lukedidit) auf CodePen.

Behandlung mehrerer Umschaltungen

Somit haben wir eine wiederverwendbare Funktion, die jede Klasse auf jedem Element umschaltet. Diese Klickereignisse können ohne zusätzlichen JavaScript-Code durch die Verwendung benutzerdefinierter Datenattribute eingerichtet werden. Es gibt jedoch noch Möglichkeiten, diese wiederverwendbare Funktion noch nützlicher zu machen.

Zurück zu unserem vorherigen Beispiel der Login-Formular-Komponente, was ist, wenn beim Klicken auf das c-button-Element zusätzlich zum Umschalten von is-loading auf js-form-area auch is-disabled auf allen Instanzen von c-input umgeschaltet werden soll? Im Moment ist das nicht möglich, da unsere benutzerdefinierten Attribute nur einen einzigen Wert pro Stück akzeptieren.

Lassen Sie uns unsere Funktion modifizieren, sodass sie anstelle jedes benutzerdefinierten Datenattributs, das nur einen einzelnen Wert akzeptiert, eine durch Kommas getrennte Liste von Werten akzeptiert – wobei jeder Wert in data-class mit dem Wert eines übereinstimmenden Index in data-class-element und data-class-scope verknüpft wird.

So zum Beispiel

<button class="c-button" data-class="is-loading, is-disabled" data-class-element="js-form-area, js-input" data-class-scope="false, js-form-area">Submit</button>

Unter Annahme der obigen Verwendung würde Folgendes geschehen, sobald c-button angeklickt wird

  1. is-loading wird auf js-form-area umgeschaltet.
  2. is-disabled wird auf js-input umgeschaltet und innerhalb des übergeordneten js-form-area-Elements gekapselt.

Dies erfordert weitere Änderungen an unserem JavaScript

// Grab all elements with data-active attribute
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-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];
            // Do
            for(var c = 0; c < elemRef.length; c++) {
                elemRef[c].classList.toggle(elemClass);
            }
        }

    });    
}

Und hier ist ein weiteres funktionierendes Beispiel

Siehe den Stift #5) Formular-Komponente mit verbesserter wiederverwendbarer Funktion für mehrere Klassen von Luke Harrison (@lukedidit) auf CodePen.

Mehr als nur umschalten

Unsere wiederverwendbare Funktion ist jetzt ziemlich nützlich, aber sie geht davon aus, dass das Umschalten von Klassen das gewünschte Verhalten ist. Was ist, wenn wir möchten, dass der Auslöser beim Klicken eine Klasse entfernt, wenn sie vorhanden ist, und ansonsten nichts tut? Derzeit ist das nicht möglich.

Um die Funktion abzurunden, integrieren wir ein paar zusätzliche Logiken, um dieses Verhalten zu ermöglichen. Wir führen ein optionales Datenattribut namens data-class-behaviour ein, das folgende Optionen akzeptiert

  • toggle – Schaltet data-class auf data-class-element um. Dies sollte auch das Standardverhalten sein, das eintritt, wenn data-class-behaviour nicht definiert ist.
  • add – Fügt data-class auf data-class-element hinzu, wenn es nicht bereits vorhanden ist. Wenn es vorhanden ist, passiert nichts.
  • remove – Entfernt data-class von data-class-element, wenn es bereits vorhanden ist. Wenn es nicht vorhanden ist, passiert nichts.

Wie bei früheren Datenattributen ist dieses neue optionale Attribut eine durch Kommas getrennte Liste, um für jede Aktion unterschiedliche Verhaltensweisen zu ermöglichen. Wie hier

<button class="c-button" data-class="is-loading, is-disabled" data-class-element="js-form-area, js-input" data-class-behaviour="toggle, remove">Submit</button>

Unter Annahme des obigen HTML-Codes würde Folgendes geschehen, sobald c-button angeklickt wird

  1. is-loading wird auf js-form-area umgeschaltet
  2. is-disabled wird aus js-input entfernt, falls vorhanden.

Nehmen wir die notwendigen JavaScript-Änderungen vor

// Grab all elements with data-active attribute
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);
                }
            }
        }

    });    
}

Und schließlich ein funktionierendes Beispiel

Siehe den Stift #6) Formular-Komponente mit verbesserter wiederverwendbarer Funktion für mehrere Klassen und Verhaltensweisen von Luke Harrison (@lukedidit) auf CodePen.

Abschluss

Was wir geschaffen haben, ist eine leistungsstarke Funktion, die immer wieder ohne zusätzlichen Code wiederverwendet werden kann. Sie ermöglicht es uns, schnell Logiken zum Hinzufügen, Entfernen oder Umschalten mehrerer zustandsbezogener Klassen per Klick zuzuweisen und diese Änderungen auf den gewünschten Bereich zu beschränken.

Es gibt noch viele Möglichkeiten, diese wiederverwendbare Funktion weiter zu verbessern

  • Unterstützung für die Verwendung anderer Ereignisse als Klick.
  • Wischunterstützung für Touch-Geräte.
  • Eine Art einfacher Validierung, die es Ihnen erlaubt, JavaScript-Variablen zu deklarieren, die wahr sein müssen, bevor eine Klassenänderung vorgenommen wird.

In der Zwischenzeit, wenn Sie Ideen für eigene Verbesserungen oder sogar eine völlig andere Methode zur Verwaltung von zustandsbezogenen Klassen haben, lassen Sie es mich bitte in den Kommentaren unten wissen.

Artikelserie

  1. Verwaltung von Zuständen in CSS mit wiederverwendbaren JavaScript-Funktionen (Sie sind hier!)
  2. Aufgreifen der Ideen in diesem Artikel