Implementing a user-friendly accordion FAQ in WordPress

A lot of clients ask me to implement a FAQ in their website. There are a couple of ways to accomplish this, but for me, this seems the best way. The main advantage of this method, is that questions/answer will be found with the default search form, and will be linked to the accordion.

I’m using Bootstrap’s accordion, which are pretty straightfoward. If you use another framework, that won’t be a problem, but you will need to change some code

Most of the code is specific to the Sage 9 starter template. Sage 9 uses Laravel’s Blade templating engine which is should be easy to read, so you could easily convert it to plain PHP.

1. Registering the post type

1.1 Custom post type

We first need to register a post type named faq, you should make the post type publicly available. If you won’t be using the post type archive to display your questions, as in this guide, you should also set has_archive to false. You should at least define support for title & editor.

'publicly_queryable'  => true,
'has_archive' => false,

1.2 Custom taxonomy

In some projects, questions should be grouped e.g. payment, shipping, repair, … If that is the case, create a custom taxonomy for this post type. I will be creating a custom taxonomy called faq_category. You could show all questions for a certain term in a term archive page, but we won’t be doing that here. We will set public to false.

'public' => false,

You can start publishing questions now.

2. Displaying all questions and answers

We will be displaying the FAQ in a custom page template named FAQ.

We need to query all terms in the faq_category taxonomy. For each term in faq_category, we fetch all posts. In Sage 9, we use controllers to pass data to templates. If you’re not using Sage, you could give the categories() method a unique name (e.g. theme_faq_categories()), and move it to functions.php.

<?php

namespace App\Controllers;

use Sober\Controller\Controller;

class TemplateFaq extends Controller
{

    /**
     * Fetch all FAQ categories and their question
     * @return array
     */
    public static function categories() {
        $return = array();
        $faq_categories = get_terms( array( 'taxonomy' => 'faq_category' ) );
        foreach ( $faq_categories as $category ) {
            $entry = array(
                'name'  => $category->name,
                'slug'  => $category->slug,
                'posts' => array()
            );
            $args = array(
                'post_type'         => 'faq',
                'posts_per_page'    => -1,
                'tax_query'         => array(
                    array(
                        'taxonomy'  => 'faq_category',
                        'field'     => 'term_id',
                        'terms'     => array( $category->term_id )
                    )
                )
            );

            $posts = get_posts( $args );
            foreach ( $posts as $key => $post ) {
                $entry['posts'][] = array(
                    'title'     => $post->post_title,
                    'content'   => $post->post_content,
                    'data'      => array( 'question' => $post->post_name )
                );
            }
            $return[] = $entry;
        }
        return $return;
    }

}

Our custom page template will call the above method, and create accordions for each category.

{{--
  Template Name: FAQ
--}}

@extends('layouts.app')

@section('content')
  @include('partials.page-header')
  @include('partials.content-page')

  @if (!have_posts())
    <div class="alert alert-warning">
      {{ __('Sorry, no results were found.', 'sage') }}
    </div>
    {!! get_search_form(false) !!}
  @else
    <div class="faq">
      @foreach( TemplateFaq::categories() as $category )
        <div class="faq-category">
          <h2 class="faq-category__name">{!! $category['name'] !!}</h2>
          <div class="faq-category__questions">
            @component('components.accordion', array( 'id' => $category['slug'], 'items' => $category['posts'] ))
            @endcomponent
          </div>
        </div>
      @endforeach
    </div>
  @endif
@endsection

This is components/accordion.blade.php:

<div class="accordion" id="{{ $id }}">
  @foreach( $items as $item )
    <div class="card" {!! \App\array_to_data_attributes( $item['data'] ) !!}>
      <div class="card-header" id="{{ $id }}-{{ $loop->index }}">
        <h2 class="mb-0">
          <button class="btn btn-link w-100 text-left" type="button" data-toggle="collapse" data-target="#collapse-{{ $id }}-{{ $loop->index }}" aria-expanded="false" aria-controls="collapse-{{ $id }}-{{ $loop->index }}">
            {!! $item['title'] !!}
          </button>
        </h2>
      </div>
      <div id="collapse-{{ $id }}-{{ $loop->index }}" class="collapse" aria-labelledby="{{ $id }}-{{ $loop->index }}" data-parent="#{{ $id }}">
        <div class="card-body">
          {!! $item['content'] !!}
        </div>
      </div>
    </div>
  @endforeach
</div>

The data-attribute data-question={post_name} is crucial in this implementation. It will make sure we can open an accordion at a specific question.

/**
 * Convert array to data attributes
 * @param  array $data
 * @return string
 */
function array_to_data_attributes( $data ) {
    $return = '';
    foreach ($data as $key => $value) {
        $return .= sprintf('data-%s="%s" ', $key, $value);
    }
    return rtrim($return, ' ');
}

At this point, you can create a new page & assign the FAQ-template to it. It should now be fully functional.

3. Improvements

This chapter is actually the main part of this post. When we search for a question in the WordPress search form, the result will link to the question’s single view, but it would be more user-friendly if it would link to the FAQ page, automatically scroll to this question and open it.

3.1 Defining the FAQ page ID

We could hard-code the FAQ page ID, or we could create a setting for this. I already have an options page for my theme (using ACF). I added a ‘Post object’ field named ‘faq_page’. We can now retrieve the value of this field using get_field( 'faq_page', 'option' ).

3.2 Changing the post link

We can use the post_type_link filter to change the link to example.com/faq/?question={post_name}.

add_filter( 'post_type_link', function( $post_link ) {
	if( get_post_type() == 'faq' && $faq_page = get_field( 'faq_page', 'option' ) ) {
		global $post;
		$post_link = sprintf( '%s?question=%s', get_permalink( $faq_page ), $post->post_name );
	}
	return $post_link;
} );

3.3 Redirecting the single view for the FAQ custom post type

add_action( 'template_redirect', function(){
	if ( is_singular( 'faq' ) && $faq_page = get_field( 'faq_page', 'option' )  ){
		global $post;
		$post_link = sprintf( '%s?question=%s', get_permalink( $faq_page ), $post->post_name );
		wp_redirect( $post_link, 301 );
		exit;
	}
	return;
} );

When you follow a link to a question, the FAQ page will now open instead of the single view.

3.4 Automatically open & scroll to the right question

The last step is adding some javascript to make the FAQ page a little more dynamic. It fetches the post_name (question) from the url, look for a question with this value as data-attribute, expand the question, and scroll to the correct position.

const url_string  = window.location.href
const url         = new URL(url_string);
const question    = url.searchParams.get('question');

if( question ) {
  const target = $('[data-question="' + question + '"]');
  $('.collapse', target).collapse('show');
  $('html, body').animate({
      scrollTop: target.offset().top,
    }, 1000, function() {
      target.focus();
    });
}

In your Sage 9 starter theme, this code would go into resources/assets/scripts/routes/faq.js. Don’t forget to import this file in resources/assets/scripts/main.js.

Overtuigd?

Neem dan contact op voor een offerte of vrijblijvend gesprek.
Stuur een e-mail naar [email protected] of maak gebruik van het contactformulier.