Obraz tytułowy: Projekt Vue.JS | Prosta wyszukiwarka z filtrami oraz użyciem transition-group

Projekt Vue.JS | Prosta wyszukiwarka z filtrami oraz użyciem transition-group

Jakiś czas temu stworzyłem prosty projekt, którego zadaniem było prezentowania „fiszek” w postaci pytanie-odpowiedź. Jako bazę pytań wykorzystane zostały przykładowe zadania, które otrzymała moja partnerka w ramach przygotowania do egzaminu.

Wygląd końcowej aplikacji

Projekt oparłem na frameworku Vue.JS. Prawdopodobnie użycie go jest lekkim przerostem formy nad treścią, natomiast chciałem przećwiczyć użycie transition-group.

Czym jest komponent transition?

Zanim przejdziemy do omówienia komponentu transition-group, poznajmy czym jest właściwie komponent transition oferowane przez Vue.JS

Komponent <transition> jest wykorzystywany do zarządzania animacjami danego pojedynczego elementu. Właściwie jest to potężne narzędzie, bo umożliwia dodawanie konkretnych efektów w zależności do tego, czy element jest wstawiany, aktualizowany, czy usuwany.

Vue w ramach tego komponentu pozwala na integracje jego z zewnętrznymi:

  • bibliotekami CSS, które posiadają gotowe klasy do animacji np. popularny Animate.css
  • zewnętrznymi bibliotekami JS służącymi do animacji m.in. Velocity.JS

Vue bierze na swoje barki odpowiednim nadawaniem klas do elementów z DOMu, czy manipulację DOMem w trakcie animacji elementu.

Standardowe proste użycie komponentu <transition>

Zgodnie z dokumentacją Vue.JS komponent <transition> wykorzystuje 6 klas na każdy ze stanów w jakim element może się znajdować w trakcie animacji. Z 6 klas, 3 klasy przeznaczone są na animacje „pojawienia się” elementu w drzewie DOM, a 3 kolejne na „opuszczanie” drzewa.

Poniższy obrazek pochodzi z dokumentacji Vue.JS obrazuje to o czym napisałem powyżej:

Prezentacja graficzna użycia klas ŹRÓDŁO

Dobrze, ale jakie są to klasy? Już tłumaczę:

  1. v-enter: dodawana jest do elementu zanim rozpoczyna się stan „pojawiania się” elementu.
  2. v-enter-active: klasa jest dodawana do elementu przez cały okres „pojawiania się” elementu, a usuwana zaraz po zakończeniu animacji.
  3. v-enter-to: dodawana przy kończeniu animacji, równocześnie z pojawieniem się jej usuwana jest klasa v-enter.
  4. v-leave: analogicznie jak v-enter tyle że dla stanu „znikania” elementu z ekranu
  5. v-leave-active: analogicznie jak v-enter-active. Usuwana po zakończeniu animacji wyjścia.
  6. v-leave-to: również analogicznie do v-enter-to. Klasa kończąca animację wyjścia.

Domyślnie w przypadku standardowego, prostego zastosowania <transition> powyższe klasy są odpowiednio nazwanymi klasami CSS np.

.fade-enter-active, .fade-leave-active {
  transition: opacity .5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}

Zwróć uwagę na to, że klasy zamiast przedrostka „v-…” mają konkretną nazwę. Nasze klasy faktycznie na miejscu słowa „fade” mogłyby mieć „v-…” ale wtedy moglibyśmy mówić o domyślnym zestawie klas do animacji stosowanych każdorazowo przy użyciu komponentu <transition>. Jeśli chcemy, aby konkretne klasy animacji były stosowane do konkretnego elementu wtedy wymyślamy jakąś nazwę i deklarujemy ją w postaci atrybutu name.

<transition name="fade">
    /* Zawartość */
 </transition>

Poniżej znajduje się działająca mini aplikacja prezentująca to co dotychczas napisałem:

Zaawansowane użycie, czyli zastosowanie Velocity.JS

Co to Velocity.JS? To silnik animacyjny wykorzystujący to samo API co klasa jQuery $.animate(), tyle że może działać z jak i bez jQuery.

Dobra, ale jak go zastosować z komponentem <transition>? Vue w ramach tego komponentu pozwala na przypisywanie zaczepów (ang. hooks) JavaScript do konkretnych stanów animacji, czy przejścia.

Posłużę się tym razem przykładem bezpośrednio z dokumentacji Vue:

<transition
  v-on:before-enter="beforeEnter"
  v-on:enter="enter"
  v-on:after-enter="afterEnter"
  v-on:enter-cancelled="enterCancelled"

  v-on:before-leave="beforeLeave"
  v-on:leave="leave"
  v-on:after-leave="afterLeave"
  v-on:leave-cancelled="leaveCancelled"
>
  <!-- ... -->
</transition>

Definicja klas w JS znajduje się poniżej:

// ...
methods: {
  // --------
  // ENTERING
  // --------

  beforeEnter: function (el) {
    // ...
  },
  // the done callback is optional when
  // used in combination with CSS
  enter: function (el, done) {
    // ...
    done()
  },
  afterEnter: function (el) {
    // ...
  },
  enterCancelled: function (el) {
    // ...
  },

  // --------
  // LEAVING
  // --------

  beforeLeave: function (el) {
    // ...
  },
  // the done callback is optional when
  // used in combination with CSS
  leave: function (el, done) {
    // ...
    done()
  },
  afterLeave: function (el) {
    // ...
  },
  // leaveCancelled only available with v-show
  leaveCancelled: function (el) {
    // ...
  }
}

Nasz kod w praktyce po implementacji Velocity.JS

Czym różni się transition-group od transition?

Tak jak wspomniałem wcześniej faktem iż transition przeznaczony jest do animacji pojedynczego elementu, a transition-group do animacji grupy elementów.

Transition-group jest szczególnie przydatne kiedy chcemy jednakowo animować elementy np. z pętli v-for.

Kilka rzeczy, o których warto wspomnień:

  • Domyślnie komponent transition-group renderuje elementy z użyciem tagu <span>. Zmiany tagu dokonujemy atrybutem np. tag=”div”
  • Klasy CSS animacji będą dodawane do elementów wewnątrz <transition-group>
  • Elementy renderowane muszą mieć kluczowe w sposób unikatowy. W przypadku pętli v-for, jeśli naszych elementów nie da się unikatowo kluczować po np. identyfikatorze, możemy zrobić następująco:
<div
    v-for="(q, index) in listOfData" /*Kluczujemy po numerze pozycji w liście za pomocą index */
    v-bind:key="index"
    v-bind:data-index="index"
>
{{q.question}}
</div>

Poza wyżej wymienionymi szczegółami <transition-group> używamy analogicznie jak zwykłego <transition>

Do dzieła

Na początku instalujemy Bootstrapa oraz Velocity-Animate (dokładna nazwa pakietu).

Bootstrapa importuje w main.js

import 'bootstrap/dist/css/bootstrap.css';
import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;

new Vue({
  render: h => h(App)
}).$mount("#app");

Tworzę prosty szkielet strony w pliku App.vue:

<template>
  <div id="app" class="container">
    <div class="row">
      <div class="col-12">
        <div class="input-group mb-3">
          <div class="input-group-prepend">
            <span class="input-group-text">Szukaj</span>
          </div>
          <input
            type="text"
            class="form-control form-control-lg"
            placeholder="Wpisz szukane pytanie"
          >
        </div>
        <div class="col-12 mt-4 px-0">
          <h2 class="h5">Wyniki dla: Zapytanie</h2>
        </div>
        <div class="col-12 mb-4 px-0">
          <p>Kategorie</p>
          <button class="btn btn-outline-light mr-2">Filtr Kategoria.1</button>
          <button class="btn btn-outline-danger">Wyczyść</button>
        </div>
      </div>
    </div>

    <div class="col-lg-4 col-12 d-flex mb-3">
      <div class="card bg-primary d-fill">
        <div class="card-header">
          <span class="badge badge-info mr-2">ID: 123</span>
          <span class="badge badge-light mb-0">Kategoria</span>
        </div>
        <div class="card-body">
          <b>Pytanie</b>
          <ul class="list-group text-dark mt-3">
            <li class="list-group-item list-group-item-success">A: Odp. A</li>
            <li class="list-group-item">B: Odp. B</li>
            <li class="list-group-item">C: Odp. C</li>
            <li class="list-group-item">D: Odp. D</li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: "App"
};
</script>
<style lang="scss">
$theme-colors: (
  "primary": #192734
);
body {
  background-color: #15202b;
  color: white;
}
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin-top: 60px;
  color: white;
}
.bg-primary{
  backgroud-color: #192734!imporant;
}
</style>

Na tym etapie nasz program wygląda następująco, proszę się nie przestraszyć mało wymiarowymi kontenerami, ale kod piszę bezpośrednio w CodeSandbox:

Pora dodać trochę logiki w Vue. Tworzymy kilka zmiennych w obiekcie data(), oraz tworzymy strukturę naszych „fiszek” z odpowiedziami.

<script>
export default {
  name: "App",
  data() {
    return {
      query: '', //Zmienna do przechowywania zapytania z inputa
      cat: ['kategoria1', 'kategoria2', 'kategoria3'], //Tablica z kategoriami
      pickedCat: '', //Zmienna na wybraną kategorie za pomocą filtru
      //Tablica z naszymi pytaniami. Pola opisane zrozumiale więc nie tłumaczę.
      questionList: [
        {
          id: 1,
          cat: 'kategoria1',
          question: 'Jeśli saldo bilansu handlowego jest ujemne, to:',
          answers:
            {
              a: 'Nie da się jednoznacznie wskazać, jakie będzie saldo bilansu płatniczego',
              b: 'Saldo bilansu płatniczego też jest ujemne',
              c: 'Eksport towarów jest wartościowo mniejszy od importu towarów',
              d: 'Jest finansowane napływem kapitału do danego kraju lub skutkuje obniżeniem rezerw walutowych banku centralnego',
              correct: ['c', 'd'],
            },
        },
      ],
    }
  }
};
</script>

Każde kolejne pytanie dodajemy w formie obiektu wewnątrz tablicy questionList. Oczywiście łatwo zauważyć, że tworząc bazę pytań na 1000 rekordów nasz plik wydłuża się znacznie, a samo dodawanie kolejnych pytań jest irytujące. Dużo lepiej byłoby stworzyć API np. na Firebase, gdzie nasze pytania będą przechowywane. Na potrzeby tego poradnika uznajmy, że nasze podejście jest rozwiązaniem wystarczającym.

Teraz zapnijmy do inputa v-model z query

 <input
     type="text"
     class="form-control form-control-lg"
     placeholder="Wpisz szukane pytanie"
     v-model="query"
>

W nagłówku Wyniki dla: Zapytania, chcemy prezentować wpisane zapytanie, a całość wyświetlać dopiero jak użytkownik wpisze zapytanie w inpucie. Do tego celu wykorzystujemy v-if oraz interpolację teksu przy użyciu nawiasów klamrowych

<div v-if="query" class="col-12 mt-4 px-0">
        <h2 class="h5">Wyniki dla: {{query}}</h2>
</div>

Pora zaprezentować kategorie w formie przycisków. Każda kategoria z tablicy cat: [] będzie prezentowana jako osobny przycisk do filtrowania. Po kliknięciu wybranego przycisku z daną kategorią za pomocą v-on:click zmienimy zawartość zmiennej pickedCat na nazwę wybranej kategorii.

Tworzymy pętlę v-for, oraz dodajemy zdarzenie v-on:click. Dodatkowo użyjemy konstrukcji warunkowej wewnątrz atrybutu :class, dzięki czemu nasz przycisk będzie się wyróżniać tak długo jak dana kategoria będzie wybrana.

<button
      v-for="(c, index) in cat"
      v-bind:key="index"
      v-bind:data-index="index"
      class="btn mr-2"
      :class=""
      v-on:click="pickedCat = c"
>
       {{c}}
</button>

Teraz dobrze byłoby dodać możliwość usunięcia filtra, czyli wrócić do stanu podstawowego naszej wyszukiwarki. W tym celu tworzymy przycisk, który pojawi się tylko jeśli zmienna pickedCat zawiera cokolwiek, oraz po kliknięciu przycisku wyzwolimy zdarzenie v-on:click, które usunie zawartość pickedCat.

 <button
            v-if="pickedCat"
            v-on:click="pickedCat = ''"
            class="btn btn-outline-danger">
            Wyczyść
</button>

Tworzenie mechanizmu do filtrowania wyników po kategorii i zapytaniu

Przechodzimy do najważniejszego punktu naszego kodu. Przy użyciu właściwości computed deklarujemy funkcję computedList() {}. Wybór własciwości computed podyktowany jest faktem, iż będziemy ciągle rerenderować rekordy po frazach oraz kategoriach w oparciu o reaktywne zmienne.

 computed: {
    computedList() {
      const vm = this;
      return this.questionList.filter((item) => (item.cat === this.pickedCat || this.pickedCat === '' ? item.question.toLowerCase().trim().indexOf(vm.query.toLowerCase().trim()) !== -1 : ''));
    },
  },

Trochę objaśnienia. Metoda .filter() zwraca nam nową tablicę w oparciu o test przeprowadzony wewnątrz niej przy użyciu funkcji strzałkowej. Poniżej zamieszczam przykład użycia tej metody na prostszym przykładzie stąd Dokumentacja Mozilli :

function isBigEnough(value) {
  return value >= 10;
}

var filtered = [12, 5, 8, 130, 44].filter(isBigEnough);
// filtered is [12, 130, 44]

Następnie wewnątrz funkcji strzałkowej w oparciu o każdy pojedynczy element nazywając go jako item przeprowadzamy porównanie. Sprawdzamy, czy kategoria rekordu jest taka sama jak kategoria wybrana w zmiennej pickedCat oraz czy wybrana kategoria jest pusta. Czemu sprawdzamy w postaci alternatywy taki warunek? Gdybyśmy tego nie zrobili zwracana tablica byłaby pusta z powodu braku dopasowania.

item.cat === this.pickedCat || this.pickedCat === ''

Następnie jeśli nasz warunek okazuje się prawdą, wykorzystując funkcję .toLowerCase() sprawiamy, że każde pytanie z obiektów będzie napisane małymi literami. Zwróć uwagę, że analogicznie zrobione jest później z zawartością zmiennej query, która zwiera szukaną frazę. Gdybyśmy tego nie zrobili to pisząc np. „Jabłko” i zakładając, że wybrane pytanie zawiera słowo „jabłko” z małej litery, to nie otrzymalibyśmy żadnego dopasowania.

item.question.toLowerCase().trim().indexOf(vm.query.toLowerCase().trim()) !== -1 : '')

Za pomocą metody .indexOf przeszukujemy tablicę w poszukiwaniu dopasowania ze zmienną query. Metoda .indexOf() zwraca wartość -1 jeśli nie znajdzie, żadnego dopasowania lub gdy wszystkiego rekordy spełniają kryterium stąd !== -1, bo szukamy wszystko różnych od.

Dobra, teraz tworzymy <transition-group> wewnątrz, którego zamieścimy pętle v-for, dla elementów zwracanych z computedList.

Nie zapomnijmy zaimportować VelocityJS

import Velocity from 'velocity-animate';

Zadeklarujemy kilka zaczepów JS wewnątrz właściwości methods(). Poniższy kod spowoduje, że nasze elementy będą mieć całkiem atrakcyjną animację wejścia i wyjścia.

 methods: {
    beforeEnter(el) {
      const e = el;
      e.style.opacity = 0;
    },
    enter(el, done) {
      const delay = el.dataset.index * 150;
      setTimeout(() => {
        Velocity(
          el,
          { opacity: 1 },
          { complete: done },
        );
      }, delay);
    },
    leave(el, done) {
      const delay = el.dataset.index;
      setTimeout(() => {
        Velocity(
          el,
          { opacity: 0 },
          { complete: done },
        );
      }, delay);
    },
  },

Nasze transition-group będzie wyglądać następująco:

 <transition-group
          id="results"
          class="row"
          name="staggered-fade"
          tag="div"
          appear
          v-bind:css="false"
          v-on:before-enter="beforeEnter"
          v-on:enter="enter"
          v-on:leave="leave"
>
</transition-group>

Deklarujemy pętlę v-for po elementach z computedList

 <div
          v-for="(q, index) in computedList"
          v-bind:key="index"
          v-bind:data-index="index"
          class="col-lg-4 col-12 d-flex mb-3"
        >
          
        </div>

Do naszych kart odpowiednio deklarujemy ich wartości. Dodatkowo robimy prosty test, aby poprawne odpowiedzi miało zielone tło.

  <div class="card bg-primary d-fill">
                <div class="card-header">
                  <span class="badge badge-info mr-2">ID: {{q.id}}</span>
                  <span class="badge badge-light mb-0">{{q.cat}}</span>
                </div>
                <div class="card-body">
                  <b>{{q.question}}</b>
                 <ul class="list-group text-dark mt-3">
                  <li
                    v-bind:class="[q.answers.correct.includes('a') ? 'list-group-item-success': '']"
                    class="list-group-item">
                    A: {{q.answers.a}}
                  </li>
                  <li
                    v-bind:class="[q.answers.correct.includes('b') ? 'list-group-item-success': '']"
                    class="list-group-item">
                    B: {{q.answers.b}}
                  </li>
                  <li
                    v-bind:class="[q.answers.correct.includes('c') ? 'list-group-item-success': '']"
                    class="list-group-item">
                      C: {{q.answers.c}}
                  </li>
                  <li
                    v-bind:class="[q.answers.correct.includes('d') ? 'list-group-item-success': '']"
                    class="list-group-item">
                    D: {{q.answers.d}}</li>
                </ul>
              </div>
            </div>

Gotowe! Nasza aplikacja działa dokładnie tak jak założyliśmy.

Działający program możecie przetestować tutaj:

Źródła:
https://vuejs.org/v2/guide/transitions.html
http://velocityjs.org/
https://developer.mozilla.org/pl/docs/Web/JavaScript/Referencje/Obiekty/Array/indexOf