ReactNode.jsJavaScriptCSS

Gatsby-Ghost Suche: React Komponente

Beispiel einer React Suchkomponente für Gatsby und dem Gatsby-Source-Ghost Plugin.

Ich möchte in diesen Artikel erläutern wie man eine simple React - Suchkomponente für Gatsby in kombination mit dem Headless CMS Ghost als Source erstellen kann.

Setup

Ihnen steht es selbstverständlich frei zu starten wie es Ihnen beliebt. Der Einfachheithalber nutze ich den gatsby-starter-ghost.

$ gatsby new suchen-beispiel https://github.com/TryGhost/gatsby-starter-ghost.git

Wir benötigen noch einige zusätzliche Module, die wir nun installieren. Vorab wechseln wir in den "suchen-beispiel" Ordner.

$ cd suchen-beispiel
$ npm install @tryghost/content-api react-icons

Eine React Komponente erstellen

Im Ordner "src/components/" erstellen wir ein Unterverzeichnis mit den Namen "/search". Dort speichern wir unsere JavaScript- und CSS-Datei.

React Komponente

Für den Anfang erstellen wir ein kleines Grundgerüst auf den wir dann später aufbauen. Hier werden schon alle benötigten Module geladen. Bis auf ein Div Element, das später als Container dienen soll, zurückgeben macht unsere Funktion noch nicht viel.

src/components/search/index.js
import React, {useRef, useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import {Link} from 'gatsby';
import {MdSearch} from 'react-icons/md';
import GhostContentAPI from '@tryghost/content-api';
import './search.css';

const ghostConfig = require('../../../.ghost.json');

const Search = ({maxResults = 10}) => {

    return (
      <>
        <div id="search-container">
          <button className={active && 'active'}>
            <MdSearch className="icon" />
          </button>
        </div>
      </>
    );
}

export default Search;

Search.propTypes = {
    maxResults: PropTypes.number
}

Hooks für State und Ref

Wir benötigen zwei States. Zum Einen den Active-State, damit wir speichern können ob die Komponente angezeigt werden soll oder nicht, und zum Anderen ein Object Array, welches die Suchergebnisse hält. Zudem benötigen wir zwei Referenzen. Eine für das Container Div Element und eine für das Input Element, welches wir auch gleich hinzufügen. Außerdem initialisieren wir die GhostContentAPI.

src/components/search/index.js
const Search = ({maxResults = 10}) => {
    const [result, setResult] = useState([]);
    const [data, setData] = useState([]);
    const [active, setActive] = useState(false);
    const containerElement = useRef(null);
    const inputElement = useRef(null);
    const api = new GhostContentAPI({
        url: process.env.NODE_ENV === `development` 
          ? ghostConfig.development.apiUrl 
          : ghostConfig.production.apiUrl,
        key: process.env.NODE_ENV === `development` 
          ? ghostConfig.development.contentApiKey 
          : ghostConfig.production.apiKey,
        version: 'v3',
    });

    return (
      <>
        <div id="search-container">
          <div id="input-container" className={active && 'active'}>
            <input type="text" ref={inputElement} 
              onChange={/* change */} placeholder="Suchen..." />
           </div>
           <button className={active && 'active'}>
             <MdSearch className="icon" />
           </button>
        </div>
      </>
    );
}

export default Search;

Search.propTypes = {
    maxResults: PropTypes.number
}

Open und Close Funktionen

Wir brauchen Funktionen, die das Anzeigen und Verstecken des Input Elements steuern.

src/components/search/index.js
const Search = ({maxResults = 10}) => {
    /*
      [...]
    */
    
    function open() {
      const input = containerElement.current.querySelector('#input-container');
      const button = containerElement.current.querySelector('button');
      const body = document.querySelector('body');
      const inputEl = inputElement.current;

      if(!active) {
        input.style.display = 'inline-block';
        inputEl.focus();
        setTimeout(() => {
            input.style.opacity = 1;
        }, 25);
      }

      setActive(!active);
    }
    
    function close() {
      const elem = e.srcElement;
      const input = containerElement.current.querySelector('#input-container');
      const button = containerElement.current.querySelector('button');
      const body = document.querySelector('body');

      if(elem.id == 'input-container' || elem.tagName == 'FORM'
      || elem.tagName == 'INPUT' || elem.tagName == 'SVG'
      || elem.tagName == 'BUTTON' || elem.tagName == 'PATH') {
        return;
      }

      setResult([]);
      body.removeEventListener('click', close);
      button.classList.remove('active');
      input.style.opacity = 0;

      setTimeout(() => {
        input.style.display = '';

        if(inputElement.current !== null)
            inputElement.current.value = '';
      }, 250);

      setActive(!active);
    }

    /*
      [...]
    */
}

Das Suchen von Beiträgen

Beim Tippen in das Input Element wird eine Suche durchgeführt und die Ergebnisse zurückgeliefert.

src/components/search/index,js
const Search = ({maxResults = 10}) => {
    /*
      [...]
    */
    
    async function change() {
      const query = inputElement.current.value.toLowerCase();

      	if(query.length > 1) {
        	const results = data.filter((obj) => {
                    if(obj.title.toLowerCase().includes(query))
                        return true;

                    for(let i = 0; i < obj.tags.length; i++) {
                        if(obj.tags[i].name.toLowerCase().includes(query))
                            return true;
                    }
                }).slice(0, maxResults);

            if(results.length > 0) {
                setResult(results.slice(0, maxResults));
            }
            else {
              setResult([]);
            }
      }
      else {
        setResult([]);
      }
    }

    /*
      [...]
    */
}

Laden der Such - Datensätze

Wir laden beim Initialisieren der Komponente die Datensätze, die durchsucht werden sollen, und erzeugen aus den Responses ein einheitliches Object Array. Das zweite Argument von useEffect ist ein Array das unterschiedliche States enthalten kann. React überprüft dann bei jeden neu rendern ob sich der State geändert hat und wenn dem nicht so ist wird der Effect nicht ausgeführt. Mit einen leeren Array [] als Argument, lässt React den Effect nur beim Mount und die zurückgegebene Funktion beim Unmount der Kompenente aus.

src/components/search/index,js
useEffect(() => {
        const fetchedData = async () => {
            const posts = await api.posts.browse({
                limit: 'all',
                fields: 'title, slug',
                include: 'tags'
            });

            const tags = await api.tags.browse({
                limit: 'all',
                fields: 'name, slug'
            });

            const pages = await api.pages.browse({
                limit: 'all',
                fields: 'title, slug',
                include: 'tags'
            });

            tags.forEach((tag, index, arr) => {
                arr[index] = {
                    title: tag.name,
                    slug: `tag/${tag.slug}`,
                    tags: [],
                    id: `${index}5s73a53051c64106bd408bcc`
                }
            });            

            setData([...posts, ...tags, ...pages]);
        }

        fetchedData();
    
        return () => {
            setData([]);
        }
}, [])

Eventhandler registrieren

Man hätte den Button Element auch einfach eine onClick Props zuweisen können um den Click Event zu triggern. Allerdings soll sich das Suchfeld schließen sobald auf etwas anderes als der Komponente geklickt wird. Daher greifen wir auf Side-Effect Hooks zurück. Diese verhalten sich wie componentDidMount, componentDidUpdate und componentWillUnmount bei React Komponenten Klassen.

src/components/search/index.js
const Search = ({maxResults = 10}) => {
    /*
      [...]
    */
    
    useEffect(() => {
      const button = containerElement.current.querySelector('button');
      button.addEventListener('click', open);

      return () => {
        button.removeEventListener('click', open);
      }
    });

    useEffect(() => {
      if(active) {
        const body = document.querySelector('body');
        const button = containerElement.current.querySelector('button');

        body.addEventListener('click', close);
        button.removeEventListener('click', open);

        return () => {
          body.removeEventListener('click', close);
        }
      }
    });

    /*
      [...]
    */
}

Rendern der Komponente

Unsere Element Struktur ist noch nicht vollständig. Darum kümmern wir uns nun.

src/components/search/index.js
const Search = ({maxResults = 10}) => {
    /*
      [...]
    */
    
    return (
      <>
        <div ref={containerElement} id="search-container">
            <div id="input-container" className={active && 'active'}>
              <input type="text" ref={inputElement} 
                onChange={change} placeholder="Suchen..." />
            </div>
            {result.length > 0 &&
              <div className="result-container">
                <ul>
                  {result.map((post) => {
                    return (
                      <li key={post.id}>
                        <Link to={`/${post.slug}`}>{post.title}</Link>
					  </li>
                    )
                  })}
                </ul>
              </div>
            }
            <button className={active && 'active'}>
              <MdSearch className="icon" />
            </button>
        </div>
    </>
    )
}

Das Style-Sheet

Auf das grundlegende Style-Sheet der Komponente möchte ich hier nicht mehr weiter eingehen.

src/components/search/search.css
#search-container {
    position: relative;
    z-index: 4;
}

#search-container > #input-container {
    display: none;
    position: absolute;
    right: 56px;
    top: -2px;
    transition: all .25s cubic-bezier(.02,.01,.47,1);
    background: #e6e6e6;
    box-shadow: 8px 0 25px 1px rgb(27, 35, 42);
    opacity: 0;
}

#search-container > #input-container input {
    color: #000;
    padding: .4rem;
    margin: .9rem;
    border: 1px solid #314860;
    box-shadow: inset 0 0 3px 1px rgb(173, 173, 173);
    width: 222px;
}

#search-container > #input-container::after {
    left: 100%;
	top: 50%;
	border: solid transparent;
	content: " ";
	height: 0;
	width: 0;
	position: absolute;
	pointer-events: none;
    border-color: rgba(230, 230, 230, 0);
	border-left-color: #c1bdbd;
	border-width: 24px;
	margin-top: -24px;
}

#search-container button {
    font-size: 3.9rem;
    padding: .1rem;
    transition: color .3s .2s ease-in-out, background .2s ease-in;
    border-radius: 50%;
    color: #fff;
    background: transparent;
}


#search-container button::before {
    content: " ";
    position: absolute;
    display: inline-block;
    transform: scale(0);
    width: 44px;
    height: 44px;
    top: -1px;
    left: -1px;
    border: solid 1px #fff;
    border-radius: 50%;
    z-index: 3;
    transition:
        background .3s .2s cubic-bezier(.02,.01,.47,1),
        transform .4s cubic-bezier(.02,.01,.47,1),
        border .3s .2s cubic-bezier(.02,.01,.47,1),
        box-shadow .25s cubic-bezier(.02,.01,.47,1);

    transform: scale(-5px);
}
#search-container button.active::before {
    background:rgb(136, 140, 146);
    transform: scale(1);
    box-shadow: 
        0 5px 10px rgba(0,0,0,0.25), 
        inset 0 0 15px rgba(255,255,255,.5), 
        inset 0 0 3px rgba(0,0,0,.5);
    transition:
        transform .3s .2s cubic-bezier(.02,.01,.47,1),
        background .3s cubic-bezier(.02,.01,.47,1),
        border .4s cubic-bezier(.02,.01,.47,1)
        box-shadow .25s cubic-bezier(.02,.01,.47,1);
}

#search-container button.active {
    color: #28333e;
    background: rgba(193, 189, 189, .25);
}

#search-container button .icon {
    position: relative;
    z-index: 4;
}

.result-container {
    position: absolute;
    top: 45px;
    right: 56px;
    width: 240px;
    box-shadow: 8px 0 25px 1px rgba(0, 0, 0,.25);
}

.result-container ul {
    list-style-type: none;
    margin: 0;
    padding: 0;
    display: block;
    background: #fff;
}

.result-container ul li {
    display: block;
    padding: .1rem;
    margin: 0;
    padding: 0;
    border-bottom: 1px solid #ccc;
    transition: background .2s ease-in;
}

.result-container ul li:last-child {
    border-bottom: none;
}

.result-container ul li:hover {
    background: #efefef;
}

.result-container ul li a {
    display: block;
    padding: .9rem;
}

.result-container ul li:hover a {
    text-decoration: none;
}

Einbinden der Komponente

Das einbinden ist relativ einfach und wird in folgenden Code Abschnitt gezeigt.

src/components/common/Layout.js
import React from 'react'
import PropTypes from 'prop-types'
import Helmet from 'react-helmet'
import { Link, StaticQuery, graphql } from 'gatsby'
import Img from 'gatsby-image'
// Suchkomponente importieren 
import Search from '../search'

import { Navigation } from '.'
import config from '../../utils/siteConfig'

/* [...] */

const DefaultLayout = ({ data, children, bodyClass, isHome }) => {
  /* [...] */
  return (
    <>
      <header className="site-head" 
        style={{ ...site.cover_image 
        && { backgroundImage: `url(${site.cover_image})` } }}>
        <div className="container">
          <div className="site-mast">
            <div className="site-mast-left">
              {/* [...] */}
            </div>
            <div className="site-mast-right">
              {/* Suchkomponente */}
              <Search />
              { site.twitter && 
              <a href={ twitterUrl } className="site-nav-item" 
              target="_blank" rel="noopener noreferrer">
                <img className="site-nav-icon" 
                  src="/images/icons/twitter.svg" alt="Twitter" /></a>}
              {/* [...] */}
            </div>
          </div>
          { isHome ?
            <div className="site-banner">
                <h1 className="site-banner-title">{site.title}</h1>
                <p className="site-banner-desc">{site.description}</p>
            </div> :
            null}
          <nav className="site-nav">
            {/* [...] */}
          </nav>
        </div>
      </header>
      {/* [...] */}
    </>
  )
}

Diese Implementation kann bei einem großen Datensatz zum Blockieren des UI führen. Im Artikel Gatsby-Ghost Suche: Webworker wird erläutert wie man dieses Beispiel um einen Webworker erweitern kann, um das Blockieren zu vermeiden.


Weitere Blog Posts aus dieser Serie

  1. Gatsby-Ghost Suche: React Komponente
  2. Gatsby-Ghost Suche: Webworker
  3. Gatsby-Ghost Suche: Content indizieren
  4. Gatsby-Ghost Suche: Redis und RediSearch (Teil 1)
  5. Gatsby-Ghost Suche: Redis und RediSearch (Teil 2)
  6. Gatsby-Ghost Suche: Redis und RediSearch (Teil 3)