WordPress Logo WordPress AJAX で投稿をフィルタリング(並べ替え)

WordPress で AJAX を使って投稿を並べ替えて表示する方法についての覚書です。

基本的なカテゴリーのみを対象に並べ替える方法からカテゴリーに加えて並び順やキーワードを指定しての並び替え、投稿の追加読み込み、change イベントでフィルタリングを実行する方法などについて。

また、AJAX は jQuery を使う方法と素の JavaScript(XMLHttpRequest 及び Fetch API)で実装する方法の両方を掲載しています。

関連ページ:

更新日:2023年07月18日

作成日:2023年7月09日

カテゴリー別のコンテンツを取得

基本的なカテゴリーのみを対象に投稿を並べ替える方法です。

表示したいカテゴリーを選択するフィルターを form 要素を使って作成し、選択されたカテゴリーの投稿を AJAX で非同期に(ページの再読み込みなしに)表示します。

この例では AJAX のリクエスト送信先を form 要素の action 属性に指定し、アクション名と nonce を form 要素内の隠し要素(input 要素)に指定してフォームのデータとしてまとめて送信します。

関連項目:form 要素を使う例

フィルターのフォーム

任意のテンプレートでフィルターを表示したい位置に以下を記述します。

get_terms() と select 要素で現在登録されている全てのカテゴリーを選択肢に持つセレクトボックスを表示し、リクエストを送信するボタンと出力先を配置します。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
    if ($terms = get_terms(
      array(
        // カテゴリーを指定(必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 並び順は名前順
        'orderby' => 'name',
        // 特定のカテゴリーを除外する場合は exclude に id を指定
        //'exclude' => '1,5'
      )
    )) :
    echo '<select name="category"><option value="">カテゴリーを選択</option>';
    foreach ($terms as $term) :
      echo '<option value="'. $term->term_id .'">' . $term->name . '</option>';
    endforeach;
    echo '</select>';
  endif;
  ?>
  <button>表示</button>
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
</form>
<div id="response"><!-- 出力先 --></div>

form の action 属性

フォームの送信先を指定する action 属性には WordPress の AJAX リクエスト送信先を指定します。

WordPress の標準の AJAX リクエストの送信先は /wp-admin/admin-ajax.php なので、admin_url('admin-ajax.php') で出力します。

JavaScript ではこの値を参照して AJAX のリクエストを送信します。

get_terms()

get_terms() はカテゴリーやタグなどのタームオブジェクトを取得してその配列を返す関数です。

get_terms() で取得したタームオブジェクトの配列を foreach でループしてその id と名前を option 要素に出力してセレクトボックスの選択肢としています。

この例では get_terms() の taxonomy に category を指定して標準のカテゴリーを表示していますが、タグやカスタムタクソノミーを指定することもできます。

上記の taxonomy に指定する値は サーバー側処理のクエリパラメータ tax_query の taxonomy と一致させる必要があります。

特定のカテゴリーなどのタームを除外する場合は、exclude に除外するタームの id を指定します。

name="action" の input 要素

name 属性に action を指定した input 要素は、アクション名を送信するための隠し要素です。

フォームをシリアライズする際に、キー名が action、値がアクション名(my_filter)のパラメータとしてエンコードされて送信されます。

WordPress はこのアクション名が付けられた AJAX アクションを呼び出して処理を実行します。

name="filter_nonce" の input 要素

name 属性に filter_nonce を指定した input 要素は、nonce の値を送信するための隠し要素です。

name 属性の値(filter_nonce)は nonce を識別するためのキー名になります。

サーバー側で nonce の値を検証する際に check_ajax_referer() の第2引数に指定します。

name 属性の値を _ajax_nonce(nonce キーのデフォルト)とすれば、check_ajax_referer() の第2引数は省略できます。

出力先の div 要素(div#response)

出力先(表示部分)の div#response は空にしてありますが、初期表示したいテキストなどを記述しておくこともできます。サブループで任意の投稿を出力しておくこともできます。

但し、この例の場合はフィルタを実行するとレスポンス(取得結果)により置き換わります。

予め投稿の一覧を表示しておき、その一覧を並べ替える方法はサブループを並べ替えを御覧ください。

テンプレートパーツ

直接テンプレートに記述するのではなく、テンプレートパーツとしてフォームを保存しておけば get_template_part() を使って読み込むことができます。

例えば、上記フォームを template-parts というフォルダに filter-cat.php という名前で保存すれば、任意のテンプレートで get_template_part( 'template-parts/filter', 'cat' ) で読み込むことができます。

AJAX 用 JavaScript ファイル

リクエストを送信してレスポンスを取得する AJAX 用 JavaScript ファイルを作成します。

以下は jQuery を使ったコードです。

フォームの submit イベントで jQuery の $.ajax() を使って AJAX のリクエストを送信します。

submit イベントはフォームのボタンがクリックされるか、コントロール(この場合はセレクトボックス)がアクティブな状態で return キーを押すと発生します。

カテゴリーが選択されていない場合はメッセージを出力し、リクエストは送信せずに終了します。

js/ajax-filter.js(jQuery)
jQuery(function($){
  // フォーム
  const filter = $('#ajax-filter');
  // フォームのボタン
  const filterBtn = filter.find('button');
  // カテゴリーのセレクトボックス
  const catSelect = $('select[name="category"]');
  // 出力先
  const responseDiv = $('#response');

  // フォームの submit イベント
  filter.submit(function(){

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.val() === '') {
      responseDiv.text('カテゴリーを選択してください。');
      return false;
    }

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filter.attr('method'),
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        filterBtn.text('取得中...');
      }
    })
    .done( function( response ) { //成功した時の処理
      // ボタンのテキストを戻す
      filterBtn.text('表示');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      responseDiv.html(response);
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      filterBtn.text('表示');
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('エラーが発生しました。');
    });

    // ブラウザのデフォルトの動作をキャンセル
    return false;
  });
});

リクエスト送信先は form の action 属性に出力されている admin_url('admin-ajax.php') を参照します。

フォームの内容は serialize() メソッドでシリアライズ(フォーマットされたクエリ文字列に変換)して data に指定します。この例の場合以下のパラメータが送信されます。

キー名(name 属性の値) 値(value 属性の値)
category select 要素で選択されたカテゴリー(option 要素の value 属性の値:term_id)
action my_filter(AJAX アクション名)
filter_nonce wp_create_nonce('my-ajax-filter') で生成した nonce の値

例:category=5&action=my_filter&filter_nonce=40c6e44955

リクエストを発行する前に呼び出される beforeSend の処理ではボタンのテキストを変更します。

レスポンスが取得できたら、ボタンのテキストを戻し、レスポンスを出力します。

また、ページが再読込されないように return false でブラウザのデフォルトの動作をキャンセルします。

Fetch API

以下は Fetch API を使った AJAX の例です。

Fetch API や XMLHttpRequest ではフォームの内容を送信する場合、FormData() を使って各要素の name プロパティをキーに、value プロパティを値としたキーと値のペアのセット(FormData オブジェクト)に変換して送信することができます。

Fetch API には jQuery の beforeSend に該当するオプションはないので、別途関数を作成し、fetch() の前に呼び出すことでリクエストの送信前にその処理(関数)が実行されます。

また、この例では任意のテンプレートにフォームを配置してフィルタできるようにするため、すべてのページで JavaScript を読み込みます。

そのため、フォームが配置されていないページでフォームの要素が存在しないためにエラーにならないように、#ajax-filter が存在する場合にのみ AJAX の処理を実行します。

js/ajax-filter.js(Fetch API)
const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

// #ajax-filter(フォームの要素)が存在すれば
if(filter) {

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = () =>{
    // ボタンのテキストを変更
    filterBtn.textContent = '取得中...';
  }

  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }

    // ボタンのテキストを変更
    beforeSend();

    // リクエスト送信先はフォームの action 属性を参照
    fetch( filter.getAttribute('action'), {
      // 使用する HTTP メソッドはフォームの method 属性を参照
      method: filter.getAttribute('method'),
      // form 要素から FormData オブジェクトを生成してリクエストボティに指定
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        return response.text();
      } else {
        // 失敗時の処理
        filterBtn.textContent = '表示';
        responseDiv.textContent = 'エラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((text) => {
      // 成功した時の処理
      filterBtn.textContent = '表示';
      responseDiv.innerHTML = text;
    })
    .catch((error) => {
      console.warn(error);
    });
  });
}

関連ページ:Fetch API の使い方

XMLHttpRequest

以下は XMLHttpRequest を使った AJAX の例です。

XMLHttpRequest にも jQuery の beforeSend に該当するオプションはないので、別途関数を作成し、XMLHttpRequest のインスタンスを生成する前に呼び出します。

js/ajax-filter.js(XMLHttpRequest)
const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = () =>{
    // ボタンのテキストを変更
    filterBtn.textContent = '取得中...';
  }

  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }

    // ボタンのテキストを変更
    beforeSend();

    // XMLHttpRequest のインスタンスを生成
    const xhr = new XMLHttpRequest();

    // メソッドはフォームの method 属性を、送信先はフォームの action 属性を参照
    xhr.open(filter.getAttribute('method'), filter.getAttribute('action'));

    xhr.addEventListener('readystatechange', ()=> {
      if(xhr.readyState === 4){
        if(xhr.status >= 200 && xhr.status < 300){
          // 成功時の処理
          filterBtn.textContent = '表示';
          responseDiv.innerHTML = xhr.responseText;
        }else{
          // 失敗時の処理
          filterBtn.textContent = '表示';
          responseDiv.textContent = 'エラーが発生しました。';
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    // form 要素から FormData オブジェクト(キーと値のペア)を生成してリクエストボティに指定
    xhr.send(new FormData(filter));
  });
}

関連ページ:AJAX XMLHttpRequest の基本的な使い方

関数にする

素の JavaScript の場合、以下のように処理を関数として定義して呼び出すことで JavaScript のグローバル変数を減らすことができます。

以下では setupAjaxFilter() として定義した関数を、DOMContentLoaded イベントを使って DOM ツリーの構築が完了した時点でを呼び出しています。

document.addEventListener('DOMContentLoaded', () => {
  // 定義した処理を呼び出す
  setupAjaxFilter();
});

// 処理を関数としてまとめる
function setupAjaxFilter() {

  // 前述の AJAX の処理
  const filter = document.getElementById('ajax-filter');
  const filterBtn = document.querySelector('#ajax-filter button');
  const catSelect = document.querySelector('select[name="category"]');
  const responseDiv = document.getElementById('response');

  if(filter) {
    // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
    const beforeSend = () =>{
      // ボタンのテキストを変更
      filterBtn.textContent = '取得中...';
    }
    filter.addEventListener('submit', (e)=> {
      // デフォルト動作のフォーム送信をキャンセル
      e.preventDefault();
      // カテゴリーが選択されていない場合はメッセージを表示して終了
      if(catSelect.value === '') {
        responseDiv.textContent = 'カテゴリーを選択してください。';
        return;
      }
      // ボタンのテキストを変更
      beforeSend();
      // XMLHttpRequest のインスタンスを生成
      const xhr = new XMLHttpRequest();
      // メソッドはフォームの method 属性を、送信先はフォームの action 属性を参照
      xhr.open(filter.getAttribute('method'), filter.getAttribute('action'));
      xhr.addEventListener('readystatechange', ()=> {
        if(xhr.readyState === 4){
          if(xhr.status >= 200 && xhr.status < 300){
            // 成功時の処理
            filterBtn.textContent = '表示';
            responseDiv.innerHTML = xhr.responseText;
          }else{
            // 失敗時の処理
            filterBtn.textContent = '表示';
            responseDiv.textContent = 'エラーが発生しました。';
            console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
          }
        }
      });
      // form 要素から FormData オブジェクト(キーと値のペア)を生成してリクエストボティに指定
      xhr.send(new FormData(filter));
    });
  }
}

または以下のように即時関数にすれば、グローバル変数を生成しません。

// 即時関数として定義
(function() {

  // 前述の AJAX の処理
  const filter = document.getElementById('ajax-filter');
  const filterBtn = document.querySelector('#ajax-filter button');
  const catSelect = document.querySelector('select[name="category"]');
  const responseDiv = document.getElementById('response');

  if(filter) {
    // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
    const beforeSend = () =>{
      // ボタンのテキストを変更
      filterBtn.textContent = '取得中...';
    }
    filter.addEventListener('submit', (e)=> {
      // デフォルト動作のフォーム送信をキャンセル
      e.preventDefault();
      // カテゴリーが選択されていない場合はメッセージを表示して終了
      if(catSelect.value === '') {
        responseDiv.textContent = 'カテゴリーを選択してください。';
        return;
      }
      // ボタンのテキストを変更
      beforeSend();
      // XMLHttpRequest のインスタンスを生成
      const xhr = new XMLHttpRequest();
      // メソッドはフォームの method 属性を、送信先はフォームの action 属性を参照
      xhr.open(filter.getAttribute('method'), filter.getAttribute('action'));
      xhr.addEventListener('readystatechange', ()=> {
        if(xhr.readyState === 4){
          if(xhr.status >= 200 && xhr.status < 300){
            // 成功時の処理
            filterBtn.textContent = '表示';
            responseDiv.innerHTML = xhr.responseText;
          }else{
            // 失敗時の処理
            filterBtn.textContent = '表示';
            responseDiv.textContent = 'エラーが発生しました。';
            console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
          }
        }
      });
      // form 要素から FormData オブジェクト(キーと値のペア)を生成してリクエストボティに指定
      xhr.send(new FormData(filter));
    });
  }
})();

但し、以降では関数にまとめる記述は省略しています。

サーバー側の処理(PHP)

JavaScript の登録と AJAX ハンドラの定義、AJAX アクションの登録を functions.php に記述します。

JavaScript の登録

この例では AJAX を記述した JavaScript ファイルを wp_enqueue_script() を使って登録しています。

functions.php
function my_enqueue_ajax_filter_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'my-ajax-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_filter_scripts');

この例では AJAX のリクエスト送信先 URL や nonce の値はフォームの属性に PHP で出力していますが、JavaScript を登録する際に wp_add_inline_script() などを使って script タグに出力することもできます。

AJAX ハンドラの定義

AJAX ハンドラの定義では、まず check_ajax_referer() を使って nonce の値を検証します。

check_ajax_referer() の第1引数には wp_create_nonce() に指定した文字列を指定し、第2引数にはキー名を指定します。

続いて、WP_Query オブジェクトの生成に指定するパラメータ($args)を作成します。

パラメータの配列 $args には投稿タイプ(post_type)と 投稿の並び順(orderby)及び投稿の状態(post_status)を指定しています。

この例の場合、投稿のカテゴリーでフィルタしているため、post_type に post を指定していますが、カスタムタクソノミーでフィルタする場合などは必要に応じて投稿タイプを変更します。

post_status には publish を指定して公開状態の投稿のみを表示するようにします。

そして $_POST['category'] から対象とするターム(カテゴリー)を取得して、tax_query パラメータを組み立て $args の配列に追加します。

tax_query の taxonomy キーには category を指定してカテゴリーの投稿を取得していますが、post_tag やカスタムタクソノミー名を指定して標準のタグやカスタムタクソノミーでフィルタすることができます。

カテゴリー以外でフィルタする場合は、フォームの get_terms() に指定する taxonomy の値と以下の taxonomy の値とを合わせる(同じ値にする)必要があります。

すべてのタスクを完了したら wp_die() で終了します。

functions.php
// AJAX ハンドラの定義
function my_ajax_filter_handler() {

  // nonce の値を検証
  check_ajax_referer('my-ajax-filter','filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプ
    'post_type' => 'post',
    // 日付順で並べる場合
    'orderby' => 'date',
    // 投稿の状態は公開状態のものに限定(下書きが表示されないように)
    'post_status' => 'publish',
  );

  // セレクトボックスで選択された値
  if (isset($_POST['category'])){

    // WP_Query に指定するタクソノミーのクエリパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  // 上記で作成したクエリパラメータ($args)を指定してクエリオブジェクトを生成
  $query = new WP_Query($args);

  // ループで出力
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();
  } else {
    echo '該当する投稿はありませんでした。';
  }
  wp_die();  // 終了
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');
AJAX アクションの登録

定義した AJAX ハンドラを add_action() を使って AJAX アクションに登録します(上記の最後の2行)。

wp_ajax_{アクション名} はログインユーザー用 AJAX アクションの名前で、wp_ajax_nopriv_{アクション名} は非ログイン(ログインしていない)ユーザー用 AJAX アクションの名前です。

アクション名はフォームの name="action" の input 要素の value 属性に指定した値(my_filter)です。

表示件数

この例の場合、サブループのクエリパラメータに posts_per_page を指定していないので、表示する投稿数は「表示設定」の「1ページに表示する最大投稿数」の値が使われます。

表示件数を変更するには、AJAX ハンドラの定義のクエリパラメータに posts_per_page を追加します。

マッチした結果のすべてを表示するには posts_per_page に -1 を指定します。

functions.php
// AJAX ハンドラの定義
function my_ajax_filter_handler() {

  check_ajax_referer('my-ajax-filter','filter_nonce');

  $args = array(
    'orderby' => 'date',
    'post_status' => 'publish',
    // 表示件数を最大20に設定
    'posts_per_page' => 20,
  );
  ・・・中略・・・
  wp_die();
}

参考:「表示設定」の「1ページに表示する最大投稿数」の値は get_option('posts_per_page') で取得することができます。

動作確認

上記を設定すると、フィルターフォームでカテゴリーを選択してボタンをクリックすると、選択されたカテゴリーの投稿記事が表示されます。

リクエスト送信先と nonce を JS で渡す

この例では、AJAX のリクエスト送信先は form 要素の action 属性に、nonce は隠し要素(input 要素)に出力していますが、必要であれば、JavaScript の登録の際に wp_add_inline_script() を使って script タグに JavaScript の変数(オブジェクト)として出力して参照することもできます。

以下は参考までに JavaScript の登録の際に wp_add_inline_script() を使って script タグに AJAX のリクエスト送信先と nonce の値を出力して参照する例です。

フォーム

form の action 属性と nonce の値とアクション名を設定していた input 要素は不要なので削除します。

<form method="POST" id="ajax-filter">
  <?php
    if ($terms = get_terms(
      array(
        'taxonomy' => 'category',
        'orderby' => 'name',
      )
    )) :
    echo '<select name="category"><option value="">カテゴリーを選択</option>';
    foreach ($terms as $term) :
      echo '<option value="'. $term->term_id .'">' . $term->name . '</option>';
    endforeach;
    echo '</select>';
  endif;
  ?>
  <button>表示</button>
</form>
<div id="response"></div>

JavaScript ファイルの登録

JavaScript ファイルを登録する際に、wp_add_inline_script() を使って AJAX のリクエスト送信先と nonce の値を script タグに JavaScript オブジェクト(変数 my_ajax_params)として出力します。

以下の場合、AJAX のリクエスト送信先は my_ajax_params.ajaxurl で、nonce の値は my_ajax_params.my_ajax_nonce で JavaScript で参照できるようになります。

functions.php
function my_enqueue_ajax_filter_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'my-ajax-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-filter.js'),
    array('jquery'),
    filemtime(get_theme_file_path('/js/ajax-filter.js')),
    true
  );

  // AJAX のリクエスト URL と nonce を出力(追加)
  wp_add_inline_script(
    // データを渡す対象の JavaScript のハンドル名(上記で登録したハンドル名を指定)
    'my-ajax-filter-script',
    // script タグに出力する JavaScript(変数 my_ajax_params に格納されたオブジェクト)
    'const my_ajax_params = ' . json_encode(array(
      'ajaxurl' => admin_url('admin-ajax.php'),
      'my_ajax_nonce' => wp_create_nonce('my-ajax-filter'),
    )),
    // script タグを対象のスクリプトの前に出力するための指定
    'before'
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_filter_scripts');

AJAX

AJAX の JavaScript では、フォームデータを配列に変換して、アクション名と nonce を追加し、data に指定する際に $.param() を使って配列をクエリ文字列に変換します。

リクエスト送信先 URL は script タグに出力された値を参照し、HTTP メソッドは文字列で直接指定しています。コメントが付いた部分が前の例と異なる部分です。

jQuery(function($){
  const filter = $('#ajax-filter');
  const filterBtn = filter.find('button');
  const catSelect = $('select[name="category"]');
  const responseDiv = $('#response');

  filter.submit(function(){

    if(catSelect.val() === '') {
      responseDiv.text('カテゴリーを選択してください。');
      return false;
    }

    // フォームデータを配列に変換してデータ(data)に格納
    const data = filter.serializeArray();
    // データの配列にアクション名を追加
    data.push({name: 'action', value: 'my_filter'});
    // データの配列に nonce を追加
    data.push({name: 'filter_nonce', value: my_ajax_params.my_ajax_nonce,});

    $.ajax({
      // 送信先は script タグに出力された値を参照
      url: my_ajax_params.ajaxurl,
      // データの配列をシリアライズ(クエリ文字列に変換)
      data: $.param(data),
      // 使用する HTTP メソッドは文字列で直接指定
      type: 'POST',
      beforeSend: function(){
        filterBtn.text('取得中...');
      }
    })
    .done( function( response ) {
      filterBtn.text('表示');
      responseDiv.html(response);
    })
    .fail( function(error) {
      filterBtn.text('表示');
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('エラーが発生しました。');
    });

    return false;
  });
});

サーバー側の AJAX ハンドラの定義は変わりません(前述の例と同じです)。

const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {
  const beforeSend = () =>{
    filterBtn.textContent = '取得中...';
  }

  filter.addEventListener('submit', (e)=> {

    e.preventDefault();

    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }
    beforeSend();

    // form 要素から FormData オブジェクトを生成
    const data = new FormData(filter);
    // アクション名を追加
    data.append('action', 'my_filter');
    // nonce を追加
    data.append('filter_nonce', my_ajax_params.my_ajax_nonce);

    // リクエスト送信先は script タグに出力された値を参照
    fetch( my_ajax_params.ajaxurl, {
      // 使用する HTTP メソッドは文字列で直接指定
      method: 'POST',
      // 上記で作成したデータ(data)を指定
      body: data
    }).then((response) => {
      if(response.ok) {
        return response.text();
      } else {
        filterBtn.textContent = '表示';
        responseDiv.textContent = 'エラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((text) => {
      filterBtn.textContent = '表示';
      responseDiv.innerHTML = text;
    })
    .catch((error) => {
      console.warn(error);
    });
  });
}
const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {

  const beforeSend = () =>{
    filterBtn.textContent = '取得中...';
  }

  filter.addEventListener('submit', (e)=> {

    e.preventDefault();

    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }

    beforeSend();

    const xhr = new XMLHttpRequest();

    // form 要素から FormData オブジェクトを生成
    const data = new FormData(filter);
    // アクション名を追加
    data.append('action', 'my_filter');
    // nonce を追加
    data.append('filter_nonce', my_ajax_params.my_ajax_nonce);

    // メソッドは直接指定し、リクエスト送信先は script タグに出力された値を参照
    xhr.open('POST', my_ajax_params.ajaxurl);

    xhr.addEventListener('readystatechange', ()=> {
      if(xhr.readyState === 4){
        if(xhr.status >= 200 && xhr.status < 300){
          filterBtn.textContent = '表示';
          responseDiv.innerHTML = xhr.responseText;
        }else{
          filterBtn.textContent = '表示';
          responseDiv.textContent = 'エラーが発生しました。';
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    // 上記で作成したデータ(data)を指定
    xhr.send(data);
  });
}

フォームのデータを送信する場合、アクション名や nonce をフォームのデータとして送信するほうが簡単なので以降ではこの方法は使いません。

フィルターを追加

カテゴリー以外のフィルターを追加する例です。

フィルターを追加するには、フォームにチェックボックスやラジオボタンなどのコントロールを追加して、追加したパラメータをサーバー側(PHP)で指定してクエリオブジェクトを生成します。

追加したフォームのコントロールのパラメータはまとめてシリアライズされて送信されるため、AJAX 側の変更はありません。

フォーム

以下はカテゴリーに加えて表示件数、表示順、キーワードのフィルター項目を追加する例です。

form 要素にフィルター項目を select 要素や input 要素で追加します。

表示件数の初期値は get_option('posts_per_page') で「表示設定」の「1ページに表示する最大投稿数」の値を取得して指定していますが、任意の数値(件数)を指定できます。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
  echo '<select name="category"><option value="">カテゴリーを選択</option>';
  foreach ($terms as $term) :
    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
  endforeach;
  echo '</select>';
  endif;
  ?>

  <!-- 表示件数 セレクトボックス-->
  <select name="per-page">
    <?php $per_page = get_option('posts_per_page'); ?>
    <option value="<?php echo esc_attr($per_page); ?>">件数:<?php echo esc_html($per_page); ?>件</option>
    <option value="10">10件</option>
    <option value="20">20件</option>
    <option value="-1">全件</option>
  </select>

  <!-- 表示順 ラジオボタン(name 属性は同じ名前にして値は ASC または DESC) -->
  <input type="radio" name="order" value="ASC" id="order-asc">
  <label for="order-asc">古い順</label>
  <input type="radio" name="order" value="DESC" id="order-desc" checked>
  <label for="order-desc">新しい順</label>

  <!-- 絞り込みキーワード入力欄 -->
  <input type="text" name="search" value="" placeholder="キーワード" size="10">

  <button>表示</button>
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
</form>
<div id="response"></div>

サーバー側(PHP)

以下が AJAX ハンドラの定義です。

追加で送信されるパラメータを WP_Query オブジェクトを生成するクエリパラメータに追加します。

例えば、表示件数のセレクトボックスの name 属性の値は per-page なので、サーバーでは $_POST['per-page'] で受け取ります。この例では PHP の filter_input を使って $_POST['per-page'] の値を整数値として $args の posts_per_page に指定しています。

functions.php
// AJAX ハンドラの定義
function my_ajax_filter_handler() {

  // nonce の値を検証
  check_ajax_referer('my-ajax-filter','filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    // 日付順で並べる場合
    'orderby' => 'date',
    // 投稿の状態は公開状態のものに限定
    'post_status' => 'publish',
    // 表示件数のパラメータを追加
    'posts_per_page'=>filter_input(INPUT_POST,'per-page',FILTER_VALIDATE_INT),
    // 表示順(DESC または ASC)
    'order' => esc_html($_POST['order']),
    // 絞り込みキーワード(検索パラメータ s に入力値を指定)
    's' => esc_html($_POST['search'])
  );

  // セレクトボックスで選択された値
  if (isset($_POST['category'])){
    // WP_Query に指定するタクソノミーのクエリパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  // 上記で作成したクエリパラメータ($args)を指定してクエリオブジェクトを生成
  $query = new WP_Query($args);

  // ループで出力
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();
  } else {
    echo '該当する投稿はありませんでした。';
  }
  wp_die();  // 終了
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');

JavaScript の登録及び AJAX の JavaScript の記述に変更はありません。

jQuery(function($){
  // フォーム
  const filter = $('#ajax-filter');
  // フォームのボタン
  const filterBtn = filter.find('button');
  // カテゴリーのセレクトボックス
  const catSelect = $('select[name="category"]');
  // 出力先
  const responseDiv = $('#response');

  // フォームの submit イベント
  filter.submit(function(){

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.val() === '') {
      responseDiv.text('カテゴリーを選択してください。');
      return false;
    }

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filter.attr('method'),
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        filterBtn.text('取得中...');
      }
    })
    .done( function( response ) { //成功した時の処理
      // ボタンのテキストを戻す
      filterBtn.text('表示');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      responseDiv.html(response);
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      filterBtn.text('表示');
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('エラーが発生しました。');
    });

    // ブラウザのデフォルトの動作をキャンセル
    return false;
  });
});

以下のようなフィルターが表示されます。

以下はカテゴリーと表示順を指定してフィルターを実行した例です。

以下はカテゴリーとキーワードを指定した例です。

change イベントの利用

この例ではフィルターフォームの submit イベントを使っているので、選択したカテゴリーを表示するにはボタンをクリックするかフォームの要素がアクティブな状態で return キーを押す必要があります。

change イベントを利用すると、セレクトボックの選択やラジオボタンの選択の変化を検知して(ボタンをクリックせずに)フィルタリングを実行することができます。

フォーム内で発生した change イベントは上位要素の form 要素に伝播するので(イベントの移譲)、form 要素に change イベントを設定すればフォーム内のコントロールの変化を検知することができます。

フォームのボタンを削除

change イベントを利用する場合、ボタンをクリックせずにフィルタリングを実行できるのでフォームのボタンを削除します。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
  echo '<select name="category"><option value="">カテゴリーを選択</option>';
  foreach ($terms as $term) :
    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
  endforeach;
  echo '</select>';
  endif;
  ?>
  <select name="per-page">
    <?php $per_page = get_option('posts_per_page'); ?>
    <option value="<?php echo esc_attr($per_page); ?>">件数:<?php echo esc_html($per_page); ?>件</option>
    <option value="10">10件</option>
    <option value="20">20件</option>
    <option value="-1">全件</option>
  </select>
  <input type="radio" name="order" value="ASC" id="order-asc">
  <label for="order-asc">古い順</label>
  <input type="radio" name="order" value="DESC" id="order-desc" checked>
  <label for="order-desc">新しい順</label>
  <input type="text" name="search" value="" placeholder="キーワード" size="10">
  <!-- ボタンを削除 <button>表示</button> -->
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
</form>
<div id="response"></div>

サーバー側の処理(PHP)

AJAX の送信のきっかけを変更するだけなので、サーバー側の処理に変更はありません。

functions.php
function my_enqueue_ajax_filter_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'my-ajax-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_filter_scripts');


// AJAX ハンドラの定義
function my_ajax_filter_handler() {

  // nonce の値を検証
  check_ajax_referer('my-ajax-filter','filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    // 日付順で並べる場合
    'orderby' => 'date',
    // 投稿の状態は公開状態のものに限定
    'post_status' => 'publish',
    // 表示件数のパラメータを追加
    'posts_per_page'=>filter_input(INPUT_POST,'per-page',FILTER_VALIDATE_INT),
    // 表示順(DESC または ASC)
    'order' => esc_html($_POST['order']),
    // 絞り込みキーワード(検索パラメータ s に入力値を指定)
    's' => esc_html($_POST['search'])
  );

  // セレクトボックスで選択された値
  if (isset($_POST['category'])){
    // WP_Query に指定するタクソノミーのクエリパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  // 上記で作成したクエリパラメータ($args)を指定してクエリオブジェクトを生成
  $query = new WP_Query($args);

  // ループで出力
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();
  } else {
    echo '該当する投稿はありませんでした。';
  }
  wp_die();  // 終了
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');

AJAX

JavaScript(ajax-filter.js)を変更します。

submit イベントの代わりに change イベントを使い、ボタンは削除しているのでボタンのテキスト変更の処理を削除し、beforeSend の処理では出力先に「取得中...」と表示するように変更します。

また、フォームを使用しているので、テキスト入力欄がアクティブな状態で return キーを押すとフォームが送信されるので、submit イベントでは return false でデフォルトの動作をキャンセルします。

jQuery

以下は jQuery を使った AJAX の処理です。

js/ajax-filter.js(jQuery)
jQuery(function($){
  // フォーム
  const filter = $('#ajax-filter');
  // カテゴリーのセレクトボックス
  const catSelect = $('select[name="category"]');
  // 出力先
  const responseDiv = $('#response');

  // フォームの change イベントで AJAX 呼び出し
  filter.change(function(){

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.val() === '') {
      responseDiv.text('カテゴリーを選択してください。');
      return;
    }

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filter.attr('method'),
      // 送信する前の処理
      beforeSend: function(){
        // 出力先のテキストを変更
        responseDiv.text('取得中...');
      }
    })
    .done( function( response ) { //成功時の処理
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      responseDiv.html(response);
    })
    .fail( function(error) { //失敗時の処理
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('エラーが発生しました。');
    });
  });

  // フォームの submit イベント
  filter.submit(function(){
    // ブラウザのデフォルトの動作をキャンセル
    return false;
  });
});

Fetch API

以下は jQuery の代わりに Fetch API を使った例です。

jQuery 同様、submit イベントの代わりに change イベントを使い、ボタンのテキストの変更の処理を削除します。そしてフォームの submit イベントではデフォルトの動作をキャンセルします。

js/ajax-filter.js(Fetch API)
const filter = document.getElementById('ajax-filter');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {
  const beforeSend = () =>{
    // 出力先のテキストを変更
    responseDiv.textContent = '取得中...';
  }

  // change イベント
  filter.addEventListener('change', ()=> {
    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }
    // ボタンのテキストを変更
    beforeSend();
    // リクエスト送信先はフォームの action 属性を参照
    fetch( filter.getAttribute('action'), {
      // 使用する HTTP メソッドはフォームの method 属性を参照
      method: filter.getAttribute('method'),
      // form 要素から FormData オブジェクトを生成してリクエストボティに指定
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        return response.text();
      } else {
        // 失敗時の処理
        responseDiv.textContent = 'エラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((text) => {
      // 成功時の処理
      responseDiv.innerHTML = text;
    })
    .catch((error) => {
      console.warn(error);
    });
  });

  // submit イベント
  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();
  });
}

XMLHttpRequest

js/ajax-filter.js(XMLHttpRequest)
const filter = document.getElementById('ajax-filter');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {
  const beforeSend = () =>{
    responseDiv.textContent = '取得中...';
  }

  filter.addEventListener('change', ()=> {
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }
    beforeSend();
    const xhr = new XMLHttpRequest();
    xhr.open(filter.getAttribute('method'), filter.getAttribute('action'));
    xhr.addEventListener('readystatechange', ()=> {
      if(xhr.readyState === 4){
        if(xhr.status >= 200 && xhr.status < 300){
          responseDiv.innerHTML = xhr.responseText;
        }else{
          responseDiv.textContent = 'エラーが発生しました。';
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  });

  filter.addEventListener('submit', (e)=> {
    e.preventDefault();
  });
}

input イベントと Debounce の利用

change イベントの場合、セレクトボックスやラジオボタンの変更では直後にイベントが発生しますが、キーワード入力の場合、入力後フォーカスが外れた時点か、カーソルが入力欄にある状態で return キーを押さないとイベントが発生しません。

以下はキーワード入力には input イベントを使う例です。

但し、input イベントは1文字入力するごとに発生してしまうので、Debounce を使用して一定時間入力がない場合にコールバックを呼び出すようにします。

フォームとサーバー側の処理は前述の例と同じです。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
  echo '<select name="category"><option value="">カテゴリーを選択</option>';
  foreach ($terms as $term) :
    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
  endforeach;
  echo '</select>';
  endif;
  ?>
  <select name="per-page">
    <?php $per_page = get_option('posts_per_page'); ?>
    <option value="<?php echo esc_attr($per_page); ?>">件数:<?php echo esc_html($per_page); ?>件</option>
    <option value="10">10件</option>
    <option value="20">20件</option>
    <option value="-1">全件</option>
  </select>
  <input type="radio" name="order" value="ASC" id="order-asc">
  <label for="order-asc">古い順</label>
  <input type="radio" name="order" value="DESC" id="order-desc" checked>
  <label for="order-desc">新しい順</label>
  <input type="text" name="search" value="" placeholder="キーワード" size="10">
  <!-- ボタンを削除 <button>表示</button> -->
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
</form>
<div id="response"></div>
function my_enqueue_ajax_filter_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'my-ajax-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_filter_scripts');


// AJAX ハンドラの定義
function my_ajax_filter_handler() {

  // nonce の値を検証
  check_ajax_referer('my-ajax-filter','filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    // 日付順で並べる場合
    'orderby' => 'date',
    // 投稿の状態は公開状態のものに限定
    'post_status' => 'publish',
    // 表示件数のパラメータを追加
    'posts_per_page'=>filter_input(INPUT_POST,'per-page',FILTER_VALIDATE_INT),
    // 表示順(DESC または ASC)
    'order' => esc_html($_POST['order']),
    // 絞り込みキーワード(検索パラメータ s に入力値を指定)
    's' => esc_html($_POST['search'])
  );

  // セレクトボックスで選択された値
  if (isset($_POST['category'])){
    // WP_Query に指定するタクソノミーのクエリパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  // 上記で作成したクエリパラメータ($args)を指定してクエリオブジェクトを生成
  $query = new WP_Query($args);

  // ループで出力
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();
  } else {
    echo '該当する投稿はありませんでした。';
  }
  wp_die();  // 終了
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');

jQuery

以下は jQuery を使った AJAX の例です

Debounce 関数を定義します。この関数は第1引数に対象の関数を、第2引数に待機時間をミリ秒で指定すると、指定された時間待機させるように拡張したラッパー関数を返すので、戻り値の拡張された関数をコールバック関数に指定します。

AJAX 呼び出しの関数 ajaxCall() を別途定義して、form 要素の change イベントで ajaxCall() を呼び出します。但し、キーワード入力は input イベントを使用するので除外します。

input イベントで ajaxCall() を呼び出すと、1文字入力するごとに AJAX の呼び出しが発生してしまうので、Debounce 関数で文字の入力後 600ms経過したら呼び出すように拡張した debouncedAjaxCall() を呼び出します。どのぐらい待機させるかは debounce() の第2引数にミリ秒で調整します。

js/ajax-filter.js(jQuery)
jQuery(function($){

  //Debounce 関数
  const debounce = (func, timeout) => {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, timeout);
    }
  }

  const filter = $('#ajax-filter');
  const catSelect = $('select[name="category"]');
  const responseDiv = $('#response');

  // AJAX の呼び出しを関数に定義
  function ajaxCall() {
    if(catSelect.val() === '') {
      responseDiv.text('カテゴリーを選択してください。');
      return;
    }
    $.ajax({
      url: filter.attr('action'),
      data: filter.serialize(),
      type: filter.attr('method'),
      beforeSend: function(){
        responseDiv.text('取得中...');
      }
    })
    .done( function( response ) {
      responseDiv.html(response);
    })
    .fail( function(error) {
      console.warn(`失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('エラーが発生しました。');
    });
  }

  // form 要素の change イベントで AJAX を呼び出す
  filter.change(function(e){
    // キーワード入力は input イベントを使用するので除外
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
  $('[name="search"]').on('input', function() {
    debouncedAjaxCall();
  })

  filter.submit(function(){
    // submit イベントではデフォルトの動作をキャンセル
    return false;
  });
});

Fetch API

以下は Fetch API を使った例です。

js/ajax-filter.js(Fetch API)
//Debounce 関数
const debounce = (func, timeout) => {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args);
    }, timeout);
  }
}

const filter = document.getElementById('ajax-filter');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {

  const beforeSend = () =>{
    responseDiv.textContent = '取得中...';
  }

  // AJAX の送信処理を関数に定義
  const ajaxCall = () => {
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }
    beforeSend();
    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        return response.text();
      } else {
        responseDiv.textContent = 'エラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((text) => {
      responseDiv.innerHTML = text;
    })
    .catch((error) => {
      console.warn(error);
    });
  }

  // form 要素の change イベントで AJAX を呼び出す
  filter.addEventListener('change', (e)=> {
    // キーワード入力は input イベントを使用するので除外
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
  filter.querySelector('[name="search"]').addEventListener('input', ()=> {
    debouncedAjaxCall();
  });

  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();
  });
}

XMLHttpRequest

js/ajax-filter.js(XMLHttpRequest)
//Debounce 関数
const debounce = (func, timeout) => {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args);
    }, timeout);
  }
}

const filter = document.getElementById('ajax-filter');
const catSelect = document.querySelector('select[name="category"]');
const responseDiv = document.getElementById('response');

if(filter) {

  const beforeSend = () =>{
    responseDiv.textContent = '取得中...';
  }

  // AJAX の送信処理を関数に定義
  const ajaxCall = () => {
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }
    beforeSend();
    const xhr = new XMLHttpRequest();
    xhr.open(filter.getAttribute('method'), filter.getAttribute('action'));
    xhr.addEventListener('readystatechange', ()=> {
      if(xhr.readyState === 4){
        if(xhr.status >= 200 && xhr.status < 300){
          responseDiv.innerHTML = xhr.responseText;
        }else{
          responseDiv.textContent = 'エラーが発生しました。';
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  }

  // form 要素の change イベントで AJAX を呼び出す
  filter.addEventListener('change', (e)=> {
    // キーワード入力は input イベントを使用するので除外
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
  filter.querySelector('[name="search"]').addEventListener('input', ()=> {
    debouncedAjaxCall();
  });

  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();
  });
}

追加読み込み

以下は「表示設定」の「1ページに表示する最大投稿数」または AJAX ハンドラで posts_per_page で設定した値よりマッチした投稿数が多い場合は「もっと読み込む」というボタンを表示し、クリックすると追加で記事を読み込む例です。

最初の例ではシンプルにフィルターはカテゴリのみで submit イベントを使います。

フォーム

現在のページ番号を設定する type="hidden" を指定した input 要素(非表示)を追加します。

name 属性にはリクエストのキーとなる paged を設定し、初期値として value 属性に1を指定します。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
    echo '<select name="category"><option value="">カテゴリーを選択</option>';
    foreach ($terms as $term) :
      echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
    endforeach;
    echo '</select>';
  endif;
  ?>
  <button>表示</button>
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
  <input type="hidden" name="paged" value="1"><!-- paged の値を設定する要素を追加 -->
</form>
<div id="response"></div>

AJAX ハンドラ(サーバー側)

以下が AJAX ハンドラの定義です。

リクエストの $_POST['paged'] から現在のページ番号を取得し、タクソノミーのパラメータとともに WP_Query に指定してクエリオブジェクトを生成をし、 その max_num_pages プロパティからページの合計数を取得します。

レスポンスは HTML とページ総数を wp_send_json() を使って値を JSON に変換して出力しています。

wp_send_json() は引数に配列やオブジェクトを受け取り、JSON に変換した値を AJAX リクエストに送り返す関数です。wp_send_json() を使う場合は、wp_die() を省略できます。

functions.php
// AJAX ハンドラの定義
function my_ajax_filter_handler() {
  // nonce の値を検証
  check_ajax_referer('my-ajax-filter', 'filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    'orderby' => 'date',
    'post_status' => 'publish',
    // リクエストの $_POST['paged'](ページ番号)を paged に指定
    'paged' => filter_input(INPUT_POST, 'paged', FILTER_VALIDATE_INT),
    // 表示件数(「表示設定」の「1ページに表示する最大投稿数」と異なる場合に指定)
    //'posts_per_page' => 4,
  );

  if (isset($_POST['category'])) {
    // WP_Query に指定するタクソノミーのパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  //クエリオブジェクトを生成
  $query = new WP_Query($args);
  // ページの合計数を上記で生成した WP_Query($query)から取得
  $max_num_pages = $query->max_num_pages;

  // 出力する HTML 文字列(レスポンス)の初期化
  $posts_html = '';

  // 出力をバッファ
  ob_start();

  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();

    // 上記ループの出力を変数に代入
    $posts_html .= ob_get_clean();
  } else {
    $posts_html = '<p>該当する投稿はありませんでした。</p>';
  }

  // レスポンスのデータ(ページ総数と出力の HTML)
  $return = array(
    'max_page' => $max_num_pages,
    'content' => $posts_html
  );

  // JSON レスポンスを出力(JSON に変換して出力)
  wp_send_json($return);
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');

JavaScript ファイルの登録は同じです。

function my_enqueue_ajax_filter_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'my-ajax-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_filter_scripts');

AJAX(JavaScript)

jQuery

以下が jQuery を使った AJAX の処理です。

フィルターでカテゴリーを選択して「表示」ボタンをクリックした際の submit イベントと、「もっと読み込む」のボタンがクリックされた際の click イベントで AJAX を使ってリクエストを送信します。

レスポンスは JSON 形式で受け取るので $.ajax() では dataType に json を指定します。

この例の場合、初期状態で何も表示されていないので、もっと読み込むのボタン(以降追加ボタンと呼びます)を作成しておいて、レスポンスのページ総数が2以上の場合に追加するようにしています。

追加ボタンが表示されている状態でフィルターを変更して、追加ボタンをクリックするとページ番号がおかしなことになるので、追加ボタンが表示されている状態でフィルターを変更したら追加ボタンを非表示にしています(17行目)。もう少し工夫したほうが良いかもしれません。

また、追加ボタンをクリックするとページ番号が増加するので、フィルターの表示ボタンをクリックしてフィルタリングする際はページ番号を1にリセットしています(29行目)。

js/ajax-filter.js(jQuery)
jQuery(function($){
  // フォーム
  const filter = $('#ajax-filter');
  // フォームのボタン
  const filterBtn = filter.find('button');
  // カテゴリーのセレクトボックス
  const catSelect = $('select[name="category"]');
  // 投稿出力先
  const responseDiv = $('#response');
  // ページ番号を設定した input 要素
  const pagedInput =  $('[name="paged"]');
  // もっと読み込むのボタンを作成して変数に代入しておく
  const moreBtn = $('<button id="loadmore" type="button">もっと読み込む</button>');

  // フィルタが変更されたらもっと読み込むのボタンを非表示に
  filter.change(function(){
    moreBtn.hide();
  });

  // フォームの submit イベント
  filter.submit(function(){
     // カテゴリーが選択されていない場合はメッセージを表示して終了
     if(catSelect.val() === '') {
      responseDiv.text('カテゴリーを選択してください。');
      return false;
    }

    // ページ番号を1にリセット
    pagedInput.val(1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filter.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // フィルタボタンのテキストを変更
        filterBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // フィルタボタンのテキストを戻す
      filterBtn.text('表示');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      responseDiv.html(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.hide();
      } else {
        // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
        if(!document.getElementById('loadmore')) {
          responseDiv.after(moreBtn);
          // フィルタが変更されると非表示になるので表示させる
          moreBtn.show();
        }else{
          // すでに追加されていれば表示
          moreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      // フィルタボタンのテキストを変更
      filterBtn.text('表示');
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('フィルタリングでエラーが発生しました。');
    });

    // ブラウザのデフォルトの動作をキャンセル
    return false;
  });

  // 追加読み込みボタンの click イベント
  moreBtn.click(function(){
    // ページ番号を1増加
    pagedInput.val(parseInt(pagedInput.val()) + 1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照
      type: filter.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        moreBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // 追加読み込みボタンのテキストを戻す
      moreBtn.text('もっと読み込む');
      // レスポンスを挿入(既存の内容にレスポンスを追加)
      responseDiv.append(data.content);

      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.hide();
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(pagedInput.val())  === data.max_page) {
          moreBtn.hide();
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          moreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      moreBtn.text('もっと読み込む');
      console.warn(`追加読み込み失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('追加読み込みでエラーが発生しました。');
    });
  });
});

Fetch API

以下は上記を Fetch API で書き換えたコードです。

js/ajax-filter.js(Fetch API)
const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('[name="category"]');
const responseDiv = document.getElementById('response');
const pagedInput = document.querySelector('[name="paged"]');

const moreBtn = document.createElement('button');
moreBtn.setAttribute('id', 'loadmore');
moreBtn.setAttribute('type', 'button');
moreBtn.textContent = 'もっと読み込む';

if(filter){

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = (btn) =>{
    // ボタンのテキストを変更
    btn.textContent = '取得中...';
  }

  // フィルタが変更されたらもっと読み込むのボタンを非表示に
  filter.addEventListener('change', () => {
    moreBtn.style.display = 'none';
  })

  // フォームの submit イベント
  filter.addEventListener('submit', (e) => {
    e.preventDefault();

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if(catSelect.value === '') {
      responseDiv.textContent = 'カテゴリーを選択してください。';
      return;
    }

    // ボタンのテキストを変更
    beforeSend(filterBtn);

    // ページ番号を1にリセット
    pagedInput.value = 1;

    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        //  レスポンスの json() メソッドで JSON として解析
        return response.json();
      } else {
        filterBtn.textContent = '表示';
        responseDiv.textContent = 'フィルタリングでエラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((data) => {
      filterBtn.textContent = '表示';
      // data は JSON オブジェクト(出力する HTML は data.content)
      responseDiv.innerHTML = data.content;
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.style.display = 'none';
      } else {
        // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
        if(!document.getElementById('loadmore')) {
          responseDiv.after(moreBtn);
          // フィルタが変更されると非表示になるので表示させる
          moreBtn.style.display = 'block';
        }else{
          moreBtn.style.display = 'block';
        }
      }
    })
    .catch((error) => {
      console.warn(error);
    });
  });

  moreBtn.addEventListener('click', () => {
    // ページ番号を1増加
    pagedInput.value = parseInt(pagedInput.value) + 1;

    // ボタンのテキストを変更
    beforeSend(moreBtn);

    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        //  レスポンスの json() メソッドで JSON として解析
        return response.json();
      } else {
        moreBtn.textContent = 'もっと読み込む';
        responseDiv.textContent = '追加読み込みでエラーが発生しました。';
        throw new Error(`追加読み込み失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((data) => {
      moreBtn.textContent = 'もっと読み込む';
      // レスポンスを挿入(既存の内容にレスポンスを追加)
      responseDiv.insertAdjacentHTML('beforeend', data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.style.display = 'none';
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(pagedInput.value)  === data.max_page) {
          moreBtn.style.display = 'none';
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          moreBtn.style.display = 'blaock';
        }
      }
    })
    .catch((error) => {
      console.warn(error);
    });
  });
}
js/ajax-filter.js(XMLHttpRequest)
const filter = document.getElementById("ajax-filter");
const filterBtn = document.querySelector("#ajax-filter button");
const catSelect = document.querySelector('[name="category"]');
const responseDiv = document.getElementById("response");
const pagedInput = document.querySelector('[name="paged"]');

const moreBtn = document.createElement("button");
moreBtn.setAttribute("id", "loadmore");
moreBtn.setAttribute("type", "button");
moreBtn.textContent = "もっと読み込む";

if (filter) {

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = (btn) =>{
    // ボタンのテキストを変更
    btn.textContent = '取得中...';
  }

  // フィルタが変更されたらもっと読み込むのボタンを非表示に
  filter.addEventListener("change", () => {
    moreBtn.style.display = "none";
  });

  // フォームの submit イベント
  filter.addEventListener("submit", (e) => {
    e.preventDefault();

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if (catSelect.value === "") {
      responseDiv.textContent = "カテゴリーを選択してください。";
      return;
    }

    // ページ番号を1にリセット
    pagedInput.setAttribute("value", 1);

    // ボタンのテキストを変更
    beforeSend(filterBtn);

    const xhr = new XMLHttpRequest();
    // responseType プロパティに 'json' を設定
    xhr.responseType = "json";
    xhr.open(filter.getAttribute("method"), filter.getAttribute("action"));
    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          filterBtn.textContent = "表示";
          // JSON として解析された受信データ(JSON オブジェクト)
          const data = xhr.response;
          responseDiv.innerHTML = data.content;
          // ページ総数が2未満の場合は追加読み込みボタンは非表示
          if (data.max_page < 2) {
            moreBtn.style.display = "none";
          } else {
            // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
            if (!document.getElementById("loadmore")) {
              responseDiv.after(moreBtn);
              // フィルタが変更されると非表示になるので表示させる
              moreBtn.style.display = "block";
            } else {
              moreBtn.style.display = "block";
            }
          }
        } else {
          responseDiv.textContent = "フィルタリングでエラーが発生しました。";
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  });

  // 追加読み込みボタンの click イベント
  moreBtn.addEventListener("click", () => {
    // ページ番号を1増加
    pagedInput.value = parseInt(pagedInput.value) + 1;

    // ボタンのテキストを変更
    beforeSend(moreBtn);

    const xhr = new XMLHttpRequest();
    // responseType プロパティに 'json' を設定
    xhr.responseType = "json";
    xhr.open(filter.getAttribute("method"), filter.getAttribute("action"));
    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          // JSON として解析された受信データ(JSON オブジェクト)
          const data = xhr.response;
          moreBtn.textContent = "もっと読み込む";
          // レスポンスを挿入(既存の内容にレスポンスを追加)
          responseDiv.insertAdjacentHTML("beforeend", data.content);
          // ページ総数が2未満の場合は追加読み込みボタンは非表示
          if (data.max_page < 2) {
            moreBtn.style.display = "none";
          } else {
            // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
            if (parseInt(pagedInput.value) === data.max_page) {
              moreBtn.style.display = "none";
            } else {
              // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
              moreBtn.style.display = "blaock";
            }
          }
        } else {
          responseDiv.textContent = "追加読み込みでエラーが発生しました。";
          console.log(`追加読み込み失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  });
}

change イベントの利用

以下は前述の例を submit イベントの代わりに change イベントで書き換えた例です。

セレクトボックスでカテゴリーを選択するとフィルタが実行されます。ボタンは不要なので削除します。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
    if ($terms = get_terms(
      array(
        'taxonomy' => 'category',
        'orderby' => 'name'
      )
    )) :
    echo '<select name="category"><option value="">カテゴリーを選択</option>';
    foreach ($terms as $term) :
      echo '<option value="'. $term->term_id .'">' . $term->name . '</option>';
    endforeach;
    echo '</select>';
  endif;
  ?>
  <!-- 削除  <button>表示</button> -->
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
 <input type="hidden" name="paged" value="1">
</form>
<div id="response"></div>

以下が AJAX(jQuery)の記述です。

前述の例の「表示」ボタンの処理を変更し、submit イベントを change イベントに変更しています。

また、カテゴリーを選択しないとフィルタできないので、カテゴリーが選択されていない場合の処理やフィルタが変更されたらもっと読み込むのボタンを非表示にする処理は不要なので削除します。

js/ajax-filter.js(jQuery)
jQuery(function($){
  // フォーム
  const filter = $('#ajax-filter');
  // 投稿出力先
  const responseDiv = $('#response');
  // ページ番号を設定した input 要素
  const pagedInput =  $('[name="paged"]');
  // もっと読み込むのボタンを作成して変数に代入しておく
  const moreBtn = $('<button id="loadmore" type="button">もっと読み込む</button>');

  // フォームの change イベント
  filter.change(function(){
    // ページ番号を1にリセット
    pagedInput.val(1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filter.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // 出力先のテキストを変更
        responseDiv.text("取得中...");
      }
    })
    .done( function( data ) { //成功した時の処理
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      responseDiv.html(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.hide();
      } else {
        // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
        if(!document.getElementById('loadmore')) {
          responseDiv.after(moreBtn);
          // フィルタが変更されると非表示になるので表示させる
          moreBtn.show();
        }else{
          // すでに追加されていれば表示
          moreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('フィルタリングでエラーが発生しました。');
    });
  });

  // 追加読み込みボタンの click イベント
  moreBtn.click(function(){
    // ページ番号を1増加
    pagedInput.val(parseInt(pagedInput.val()) + 1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照
      type: filter.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        moreBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // 追加読み込みボタンのテキストを戻す
      moreBtn.text('もっと読み込む');
      // レスポンスを挿入(既存の内容にレスポンスを追加)
      responseDiv.append(data.content);

      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.hide();
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(pagedInput.val())  === data.max_page) {
          moreBtn.hide();
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          moreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      moreBtn.text('もっと読み込む');
      console.warn(`追加読み込み失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('追加読み込みでエラーが発生しました。');
    });
  });
});

サーバー側の処理は前述の例と同じです。

functions.php
function my_enqueue_ajax_filter_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'my-ajax-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_filter_scripts');


// AJAX ハンドラの定義
function my_ajax_filter_handler() {
  // nonce の値を検証
  check_ajax_referer('my-ajax-filter', 'filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    'orderby' => 'date',
    'post_status' => 'publish',
    // リクエストの $_POST['paged'](ページ番号)を paged に指定
    'paged' => filter_input(INPUT_POST, 'paged', FILTER_VALIDATE_INT),
    // 表示件数(「表示設定」の「1ページに表示する最大投稿数」と異なる場合に指定)
    //'posts_per_page' => 4,
  );

  if (isset($_POST['category'])) {
    // WP_Query に指定するタクソノミーのパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  //クエリオブジェクトを生成
  $query = new WP_Query($args);
  // ページの合計数を上記で生成した WP_Query($query)から取得
  $max_num_pages = $query->max_num_pages;

  // 出力する HTML 文字列(レスポンス)の初期化
  $posts_html = '';

  // 出力をバッファ
  ob_start();

  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();

    // 上記ループの出力を変数に代入
    $posts_html .= ob_get_clean();
  } else {
    $posts_html = '<p>該当する投稿はありませんでした。</p>';
  }

  // レスポンスのデータ(ページ総数と出力の HTML)
  $return = array(
    'max_page' => $max_num_pages,
    'content' => $posts_html
  );

  // JSON レスポンスを出力(JSON に変換して出力)
  wp_send_json($return);
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');

以下は Fetch API の場合です。

js/ajax-filter.js(Fetch API)
const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('[name="category"]');
const responseDiv = document.getElementById('response');
const pagedInput = document.querySelector('[name="paged"]');

const moreBtn = document.createElement('button');
moreBtn.setAttribute('id', 'loadmore');
moreBtn.setAttribute('type', 'button');
moreBtn.textContent = 'もっと読み込む';

if(filter){

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = (elem) =>{
    // 要素のテキストを変更
    elem.textContent = '取得中...';
  }

  // フォームの change イベント
  filter.addEventListener('change', () => {

    // 出力先のテキストを変更
    beforeSend(responseDiv);

    // ページ番号を1にリセット
    pagedInput.value = 1;

    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        //  レスポンスの json() メソッドで JSON として解析
        return response.json();
      } else {
        responseDiv.textContent = 'フィルタリングでエラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((data) => {
      // data は JSON オブジェクト(出力する HTML は data.content)
      responseDiv.innerHTML = data.content;
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.style.display = 'none';
      } else {
        // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
        if(!document.getElementById('loadmore')) {
          responseDiv.after(moreBtn);
          // フィルタが変更されると非表示になるので表示させる
          moreBtn.style.display = 'block';
        }else{
          moreBtn.style.display = 'block';
        }
      }
    })
    .catch((error) => {
      console.warn(error);
    });
  });

  moreBtn.addEventListener('click', () => {
    // ページ番号を1増加
    pagedInput.value = parseInt(pagedInput.value) + 1;

    // ボタンのテキストを変更
    beforeSend(moreBtn);

    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        //  レスポンスの json() メソッドで JSON として解析
        return response.json();
      } else {
        filterBtn.textContent = 'もっと読み込む';
        responseDiv.textContent = '追加読み込みでエラーが発生しました。';
        throw new Error(`追加読み込み失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((data) => {
      moreBtn.textContent = 'もっと読み込む';
      // レスポンスを挿入(既存の内容にレスポンスを追加)
      responseDiv.insertAdjacentHTML('beforeend', data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.style.display = 'none';
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(pagedInput.value)  === data.max_page) {
          moreBtn.style.display = 'none';
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          moreBtn.style.display = 'blaock';
        }
      }
    })
    .catch((error) => {
      console.warn(error);
    });
  });
}
js/ajax-filter.js(XMLHttpRequest)
const filter = document.getElementById("ajax-filter");
const filterBtn = document.querySelector("#ajax-filter button");
const catSelect = document.querySelector('[name="category"]');
const responseDiv = document.getElementById("response");
const pagedInput = document.querySelector('[name="paged"]');

const moreBtn = document.createElement("button");
moreBtn.setAttribute("id", "loadmore");
moreBtn.setAttribute("type", "button");
moreBtn.textContent = "もっと読み込む";

if (filter) {

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = (elem) =>{
    // 要素のテキストを変更
    elem.textContent = '取得中...';
  }

  // フォームの change イベント
  filter.addEventListener('change', () => {

    // カテゴリーが選択されていない場合はメッセージを表示して終了
    if (catSelect.value === "") {
      responseDiv.textContent = "カテゴリーを選択してください。";
      return;
    }

    // ページ番号を1にリセット
    pagedInput.setAttribute("value", 1);

    // 出力先のテキストを変更
    beforeSend(responseDiv);

    const xhr = new XMLHttpRequest();
    // responseType プロパティに 'json' を設定
    xhr.responseType = "json";
    xhr.open(filter.getAttribute("method"), filter.getAttribute("action"));
    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          // JSON として解析された受信データ(JSON オブジェクト)
          const data = xhr.response;
          responseDiv.innerHTML = data.content;
          // ページ総数が2未満の場合は追加読み込みボタンは非表示
          if (data.max_page < 2) {
            moreBtn.style.display = "none";
          } else {
            // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
            if (!document.getElementById("loadmore")) {
              responseDiv.after(moreBtn);
              // フィルタが変更されると非表示になるので表示させる
              moreBtn.style.display = "block";
            } else {
              moreBtn.style.display = "block";
            }
          }
        } else {
          responseDiv.textContent = "フィルタリングでエラーが発生しました。";
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  });

  // 追加読み込みボタンの click イベント
  moreBtn.addEventListener("click", () => {
    // ページ番号を1増加
    pagedInput.value = parseInt(pagedInput.value) + 1;

    // ボタンのテキストを変更
    beforeSend(moreBtn);

    const xhr = new XMLHttpRequest();
    // responseType プロパティに 'json' を設定
    xhr.responseType = "json";
    xhr.open(filter.getAttribute("method"), filter.getAttribute("action"));
    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          // JSON として解析された受信データ(JSON オブジェクト)
          const data = xhr.response;
          moreBtn.textContent = "もっと読み込む";
          // レスポンスを挿入(既存の内容にレスポンスを追加)
          responseDiv.insertAdjacentHTML("beforeend", data.content);
          // ページ総数が2未満の場合は追加読み込みボタンは非表示
          if (data.max_page < 2) {
            moreBtn.style.display = "none";
          } else {
            // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
            if (parseInt(pagedInput.value) === data.max_page) {
              moreBtn.style.display = "none";
            } else {
              // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
              moreBtn.style.display = "blaock";
            }
          }
        } else {
          responseDiv.textContent = "追加読み込みでエラーが発生しました。";
          console.log(`追加読み込み失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  });
}

フィルターを追加

前述の例に表示件数、表示順、キーワード(絞り込み検索)のフィルターを追加するため、フィルターのフォームにセレクトボックスとラジオボタン、テキスト入力の input 要素を追加します。

この例では フィルターを追加の例とは異なり、カテゴリーが選択されていない場合は、全てのカテゴリー(タクソノミー)を対象に指定されたフィルタを実行するようにしていします。

<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
  echo '<select name="category"><option value="">全てのカテゴリー</option>';
  foreach ($terms as $term) :
    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
  endforeach;
  echo '</select>';
  endif;
  ?>
  <select name="per-page">
    <?php $per_page = get_option('posts_per_page'); ?>
    <option value="<?php echo esc_attr($per_page); ?>">件数:<?php echo esc_html($per_page); ?>件</option>
    <option value="10">10件</option>
    <option value="20">20件</option>
    <option value="-1">全件</option>
  </select>
  <input type="radio" name="order" value="ASC" id="order-asc">
  <label for="order-asc">古い順</label>
  <input type="radio" name="order" value="DESC" id="order-desc" checked>
  <label for="order-desc">新しい順</label>
  <input type="text" name="search" value="" placeholder="キーワード" size="10">
  <input type="hidden" name="action" value="my_filter">
  <input type="hidden" name="filter_nonce" value="<?php echo wp_create_nonce('my-ajax-filter'); ?>">
  <input type="hidden" name="paged" value="1">
</form>
<div id="response"></div>

サーバー側では追加されたフィルタの値を受け取り、クエリオブジェクトを生成するクエリパラメータに追加で設定します。

今までの例ではカテゴリーが必ず選択されるようにしていましたが、この例ではカテゴリーのセレクトボックスが選択されていない場合は「全てのカテゴリー」としています。

そのため、サーバー側では $_POST['category'] が空の場合はクエリオブジェクトを生成する際にタクソノミーの条件を指定せず、全てのタクソノミーを対象としています。

JavaScript ファイルの読み込みは同じなので省略します。

functions.php
// AJAX ハンドラの定義
function my_ajax_filter_handler() {
  // nonce の値を検証
  check_ajax_referer('my-ajax-filter', 'filter_nonce');

  // WP_Query に指定するパラメータ
  $args = array(
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    'orderby' => 'date',
    'post_status' => 'publish',
    // リクエストの $_POST['paged'](ページ番号)を paged に指定
    'paged' => filter_input(INPUT_POST, 'paged', FILTER_VALIDATE_INT),
    // リクエストから取得した表示件数
    'posts_per_page'=>filter_input(INPUT_POST,'per-page',FILTER_VALIDATE_INT),
    // リクエストから取得した表示順
    'order' => esc_html($_POST['order']),
    //  リクエストから取得した絞り込みキーワード
    's' =>  esc_html($_POST['search'])
  );

  // カテゴリーが空の場合は、タクソノミーの指定をしない(全てのタクソノミーを対象とする)
  if (isset($_POST['category']) && trim($_POST['category']) !== '') {
    // WP_Query に指定するタクソノミーのパラメータ
    $args['tax_query'] = array(
      array(
        // 対象とするタクソノミー(カテゴリーを指定。必要に応じてタグなどに変更可能)
        'taxonomy' => 'category',
        // 以下の terms に指定するフィールド
        'field' => 'term_id',
        // 対象とするタームの値(セレクトボックスで選択された値 id)
        'terms' => esc_html($_POST['category'])
      )
    );
  }

  //クエリオブジェクトを生成
  $query = new WP_Query($args);
  // ページの合計数を上記で生成した WP_Query($query)から取得
  $max_num_pages = $query->max_num_pages;

  // 出力する HTML 文字列(レスポンス)の初期化
  $posts_html = '';

  // 出力をバッファ
  ob_start();

  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();

    // 上記ループの出力を変数に代入
    $posts_html .= ob_get_clean();
  } else {
    $posts_html = '<p>該当する投稿はありませんでした。</p>';
  }

  // レスポンスのデータ(ページ総数と出力の HTML)
  $return = array(
    'max_page' => $max_num_pages,
    'content' => $posts_html
  );

  // JSON レスポンスを出力(JSON に変換して出力)
  wp_send_json($return);
}
// AJAX アクションの登録
add_action('wp_ajax_my_filter', 'my_ajax_filter_handler');
add_action('wp_ajax_nopriv_my_filter', 'my_ajax_filter_handler');

以下は jQuery を使った AJAX の記述です。

フィルターが変更された際に呼び出す AJAX 処理を関数 ajaxCall() に定義しておきます。

セレクトボックスとラジオボタン(キーワード入力以外)の変更を検知したらフォームの change イベントで ajaxCall() を呼び出してフィルタを実行します。

キーワード検索の入力は input イベントで検知して、Debounce 関数で拡張した debouncedAjaxCall() を実行してフィルタリングします。

また、キーワード検索の入力欄がアクティブな状態で return キーを押すとフォームが送信されてしまうので、submit イベントで return false によりフォーム送信のデフォルト動作をキャンセルしています。

追加の読み込み部分は前述の例と同じです。

js/ajax-filter.js(jQuery)
jQuery(function($){

  //Debounce 関数
  const debounce = (func, timeout) => {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, timeout);
    }
  }

  // フォーム
  const filter = $('#ajax-filter');
  // 投稿出力先
  const responseDiv = $('#response');
  // ページ番号を設定した input 要素
  const pagedInput =  $('[name="paged"]');
  // もっと読み込むのボタンを作成して変数に代入しておく
  const moreBtn = $('<button id="loadmore" type="button">もっと読み込む</button>');

  // フィルターが変更された際に呼び出す AJAX 処理を関数に定義
  function ajaxCall() {
     // ページ番号を1にリセット
     pagedInput.val(1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filter.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // 出力先のテキストを変更
        responseDiv.text("取得中...");
      }
    })
    .done( function( data ) { //成功した時の処理
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      responseDiv.html(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.hide();
      } else {
        // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
        if(!document.getElementById('loadmore')) {
          responseDiv.after(moreBtn);
          // フィルタが変更されると非表示になるので表示させる
          moreBtn.show();
        }else{
          // すでに追加されていれば表示
          moreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('フィルタリングでエラーが発生しました。');
    });
  }

  // フォームの change イベント
  filter.change(function(e){
    // キーワード絞り込み以外の場合は AJAX 処理を呼び出す
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce 関数を呼び出す
  $('[name="search"]').on('input', function() {
    // Debounce を適用した AJAX を呼び出す
    debouncedAjaxCall(false);
  })

  // フォームの submit イベント
  filter.submit(function () {
    // デフォルトの動作をキャンセル
    return false;
  });

  // 追加読み込みボタンの click イベント
  moreBtn.click(function(){
    // ページ番号を1増加
    pagedInput.val(parseInt(pagedInput.val()) + 1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filter.attr('action'),
      // フォームデータをシリアライズ
      data: filter.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照
      type: filter.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        moreBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // 追加読み込みボタンのテキストを戻す
      moreBtn.text('もっと読み込む');
      // レスポンスを挿入(既存の内容にレスポンスを追加)
      responseDiv.append(data.content);

      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.hide();
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(pagedInput.val())  === data.max_page) {
          moreBtn.hide();
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          moreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      moreBtn.text('もっと読み込む');
      console.warn(`追加読み込み失敗: ${error.status} ${error.statusText}` );
      responseDiv.text('追加読み込みでエラーが発生しました。');
    });
  });
});

Fetch API を使った場合の例です。

js/ajax-filter.js(Fetch API)
//Debounce 関数
const debounce = (func, timeout) => {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args);
    }, timeout);
  }
}

const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('[name="category"]');
const responseDiv = document.getElementById('response');
const pagedInput = document.querySelector('[name="paged"]');

const moreBtn = document.createElement('button');
moreBtn.setAttribute('id', 'loadmore');
moreBtn.setAttribute('type', 'button');
moreBtn.textContent = 'もっと読み込む';

if(filter){

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = (elem) =>{
    // 要素のテキストを変更
    elem.textContent = '取得中...';
  }

  const ajaxCall = () => {
    // 出力先のテキストを変更
    beforeSend(responseDiv);

    // ページ番号を1にリセット
    pagedInput.value = 1;

    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        //  レスポンスの json() メソッドで JSON として解析
        return response.json();
      } else {
        responseDiv.textContent = 'フィルタリングでエラーが発生しました。';
        throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((data) => {
      // data は JSON オブジェクト(出力する HTML は data.content)
      responseDiv.innerHTML = data.content;
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.style.display = 'none';
      } else {
        // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
        if(!document.getElementById('loadmore')) {
          responseDiv.after(moreBtn);
          // フィルタが変更されると非表示になるので表示させる
          moreBtn.style.display = 'block';
        }else{
          moreBtn.style.display = 'block';
        }
      }
    })
    .catch((error) => {
      console.warn(error);
    });
  }

  // フォームの change イベント
  filter.addEventListener('change', (e) => {
    // キーワード絞り込み以外の場合は AJAX 処理を呼び出す
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
  filter.querySelector('[name="search"]').addEventListener('input', ()=> {
    debouncedAjaxCall();
  });

  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();
  });

  moreBtn.addEventListener('click', () => {
    // ページ番号を1増加
    pagedInput.value = parseInt(pagedInput.value) + 1;

    // ボタンのテキストを変更
    beforeSend(moreBtn);

    fetch( filter.getAttribute('action'), {
      method: filter.getAttribute('method'),
      body: new FormData(filter)
    }).then((response) => {
      if(response.ok) {
        //  レスポンスの json() メソッドで JSON として解析
        return response.json();
      } else {
        filterBtn.textContent = 'もっと読み込む';
        responseDiv.textContent = '追加読み込みでエラーが発生しました。';
        throw new Error(`追加読み込み失敗:${response.status} ${response.statusText}`);
      }
    })
    .then((data) => {
      moreBtn.textContent = 'もっと読み込む';
      // レスポンスを挿入(既存の内容にレスポンスを追加)
      responseDiv.insertAdjacentHTML('beforeend', data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        moreBtn.style.display = 'none';
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(pagedInput.value)  === data.max_page) {
          moreBtn.style.display = 'none';
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          moreBtn.style.display = 'blaock';
        }
      }
    })
    .catch((error) => {
      console.warn(error);
    });
  });
}
js/ajax-filter.js(XMLHttpRequest)
//Debounce 関数
const debounce = (func, timeout) => {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args);
    }, timeout);
  }
}

const filter = document.getElementById('ajax-filter');
const filterBtn = document.querySelector('#ajax-filter button');
const catSelect = document.querySelector('[name="category"]');
const responseDiv = document.getElementById('response');
const pagedInput = document.querySelector('[name="paged"]');

const moreBtn = document.createElement('button');
moreBtn.setAttribute('id', 'loadmore');
moreBtn.setAttribute('type', 'button');
moreBtn.textContent = 'もっと読み込む';

if(filter){

  // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
  const beforeSend = (elem) =>{
    // 要素のテキストを変更
    elem.textContent = '取得中...';
  }

  const ajaxCall = () => {
    // 出力先のテキストを変更
    beforeSend(responseDiv);

    // ページ番号を1にリセット
    pagedInput.value = 1;

    const xhr = new XMLHttpRequest();
    // responseType プロパティに 'json' を設定
    xhr.responseType = "json";
    xhr.open(filter.getAttribute("method"), filter.getAttribute("action"));
    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          // JSON として解析された受信データ(JSON オブジェクト)
          const data = xhr.response;
          responseDiv.innerHTML = data.content;
          // ページ総数が2未満の場合は追加読み込みボタンは非表示
          if (data.max_page < 2) {
            moreBtn.style.display = "none";
          } else {
            // 初回まだ追加読み込みボタンがドキュメントに追加されていなければ追加
            if (!document.getElementById("loadmore")) {
              responseDiv.after(moreBtn);
              // フィルタが変更されると非表示になるので表示させる
              moreBtn.style.display = "block";
            } else {
              moreBtn.style.display = "block";
            }
          }
        } else {
          responseDiv.textContent = "フィルタリングでエラーが発生しました。";
          console.log(`フィルタリング失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  }

  // フォームの change イベント
  filter.addEventListener('change', (e) => {
    // キーワード絞り込み以外の場合は AJAX 処理を呼び出す
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
  filter.querySelector('[name="search"]').addEventListener('input', ()=> {
    debouncedAjaxCall();
  });

  filter.addEventListener('submit', (e)=> {
    // デフォルト動作のフォーム送信をキャンセル
    e.preventDefault();
  });

  moreBtn.addEventListener('click', () => {
    // ページ番号を1増加
    pagedInput.value = parseInt(pagedInput.value) + 1;

    // ボタンのテキストを変更
    beforeSend(moreBtn);

    const xhr = new XMLHttpRequest();
    // responseType プロパティに 'json' を設定
    xhr.responseType = "json";
    xhr.open(filter.getAttribute("method"), filter.getAttribute("action"));
    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          // JSON として解析された受信データ(JSON オブジェクト)
          const data = xhr.response;
          moreBtn.textContent = "もっと読み込む";
          // レスポンスを挿入(既存の内容にレスポンスを追加)
          responseDiv.insertAdjacentHTML("beforeend", data.content);
          // ページ総数が2未満の場合は追加読み込みボタンは非表示
          if (data.max_page < 2) {
            moreBtn.style.display = "none";
          } else {
            // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
            if (parseInt(pagedInput.value) === data.max_page) {
              moreBtn.style.display = "none";
            } else {
              // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
              moreBtn.style.display = "blaock";
            }
          }
        } else {
          responseDiv.textContent = "追加読み込みでエラーが発生しました。";
          console.log(`追加読み込み失敗: ${xhr.status} (${xhr.statusText})`);
        }
      }
    });
    xhr.send(new FormData(filter));
  });
}

サブループを並べ替え

これまでの例はフィルターのフォームを記述して選択された条件の一覧を出力しましたが、以下は予め一覧を表示しておき、その一覧をフィルターで並べ替える例です。

内容的にはこれまでの例とほとんど同じですが、予め一覧のサブループに追加読み込みボタンを配置しておくことができます。

テンプレートパーツ

この例では以下のようなテンプレートパーツ(template-parts/filter.php)を作成して、任意のテンプレートで get_template_part() を使って読み込んで表示します。

form 要素(フォーム)を使ったフィルターと投稿の一覧を表示するサブループを記述します。

$args に表示する投稿の条件を設定してサブループを使って投稿のタイトルと抜粋を表示しています。出力する内容は必要に応じて変更できますが、サーバー側の AJAX ハンドラと合わせませす。

posts_per_page は get_option() で「表示設定」の「1ページに表示する最大投稿数」の値を参照していますが、任意の数値を指定することもできます。

ページ番号を表す paged は1にします。

生成したクエリオブジェクト($my_posts)の max_num_pages プロパティから合計のページ数を取得し、max_num_pages の値が2以上の場合は「もっと読み込む」のボタンを CSS の display プロパティを block にして表示し、2未満の場合は非表示にします。

template-parts/filter.php
<?php
// サブループのクエリパラメータ(条件)
$args = array(
  'post_type' => 'post',
  'post_status' => 'publish',
  //「表示設定」の「1ページに表示する最大投稿数」の値を参照(または任意の値を指定)
  'posts_per_page' => get_option('posts_per_page'),
  // ページ番号
  'paged' => 1,
);
// クエリ(WP_Query)オブジェクトを生成
$my_posts = new WP_Query($args);
// ページ総数を取得(追加読み込みを表示するかどうかの判定に使用)
$max_num_pages = $my_posts->max_num_pages;
?>

<!-- フィルターフォーム -->
<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-page-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
    echo '<select name="category"><option value="">全てのカテゴリー</option>';
    foreach ($terms as $term) :
      echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
    endforeach;
    echo '</select>';
  endif;
  ?>
  <select name="per-page">
    <?php $per_page = get_option('posts_per_page'); ?>
    <option value="<?php echo esc_attr($per_page); ?>">件数:<?php echo esc_html($per_page); ?>件</option>
    <option value="10">10件</option>
    <option value="20">20件</option>
    <option value="-1">全件</option>
  </select>
  <select name="order">
    <option value="DESC">新しい順</option>
    <option value="ASC">古い順</option>
  </select>
  <input type="text" name="search" value="" placeholder="キーワード" size="10">
  <button class="apply-filter">適用</button>
  <button class="reset-filter" type="button">リセット</button>
  <input type="hidden" name="action" value="page_filter">
  <input type="hidden" name="_ajax_nonce" value="<?php echo wp_create_nonce('my-ajax-page-filter'); ?>">
  <input type="hidden" name="paged" value="1">
</form>

<div id="filter-posts-wrapper">
  <!-- フィルター対象のサブループ -->
  <?php if ($my_posts->have_posts()) : ?>
    <!-- 投稿を表示する要素 -->
    <div id="filter-posts">
      <?php while ($my_posts->have_posts()) : $my_posts->the_post(); ?>
        <?php the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>'); ?>
        <?php the_excerpt(); ?>
      <?php endwhile; ?>
      <?php wp_reset_postdata(); ?>
    </div>
    <?php
    // 追加読み込み用のボタンの CSS display プロパティに指定する値
    $display = 'none';
    if ($max_num_pages > 1) $display = 'block';
    ?>
    <!-- ページ総数が2以上の場合にボタンを表示 -->
    <button style="display:<?php echo $display; ?>" id="load-more" type="button">もっと読み込む</button>
  <?php endif; ?>
</div><!-- #filter-posts-wrapper -->

任意のテンプレートで以下を記述(追加)して上記テンプレートパーツを読み込みます。

<?php get_template_part( 'template-parts/filter' );  ?>

特定のテンプレートでのみ使用する場合は、テンプレートパーツにせずに get_template_part() で読み込む代わりに、直接テンプレートに filter.php を記述することもできます。

その場合は、AJAX の JavaScript ファイルの読み込みを条件分岐タグでそのテンプレートだけで読み込むようにして不要なテンプレートでの読み込みを制限することができます。

この例ではフィルターをリセットするためのリセットボタンも表示するようにしているので上記により以下のようなフィルターが表示されます。

以下は内容的には、前述のフィルターを追加の例とほぼ同じです。

サーバー側(PHP)

サーバー側では、AJAX の JavaScript ファイル(js/ajax-page-filter.js)を登録し、リクエストを受けたときに実行する処理を AJAX ハンドラに定義して AJAX アクションとして登録します。

JavaScript ファイルの登録では、必要に応じて条件分岐タグで AJAX のファイルを読み込むテンプレートを限定することができます。以下の場合は全てのテンプレートで AJAX のファイルが読み込まれます。

AJAX ハンドラでは最初に nonce の値を検証し、クエリオブジェクトを生成して取得したページの合計数と出力の HTML を JSON レスポンスとして返します。

functions.php
function my_enqueue_ajax_page_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'ajax-page-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-page-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-page-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_page_scripts');

// AJAX ハンドラの定義
function my_ajax_page_handler() {
  // nonce の値を検証
  check_ajax_referer('my-ajax-page-filter');

  // WP_Query に指定するパラメータ
  $args = array(
    'orderby' => 'date',
    'post_status' => 'publish',
    // リクエストの $_POST['paged'](ページ番号)を paged に指定
    'paged' => filter_input(INPUT_POST, 'paged', FILTER_VALIDATE_INT),
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    // 表示件数のパラメータを追加
    'posts_per_page' => filter_input(INPUT_POST, 'per-page', FILTER_VALIDATE_INT),
    // 表示順(order に DESC または ASC を指定)
    'order' => esc_html($_POST['order']),
    // 絞り込みキーワード(検索パラメータ s に入力値を指定)
    's' => esc_html($_POST['search'])
  );
  // カテゴリーが選択されていれば $args に tax_query を追加
  if (isset($_POST['category']) && $_POST['category'] !== '') {
    $args['tax_query'] = array(
      array(
        'taxonomy' => 'category',
        'field' => 'term_id',
        'terms' => esc_html($_POST['category'])
      )
    );
  }
  //クエリオブジェクトを生成
  $query = new WP_Query($args);
  // ページの合計数を生成した WP_Query から取得
  $max_num_pages = $query->max_num_pages;
  // 出力する HTML 文字列(レスポンス)の初期化
  $posts_html = '';
  // 出力をバッファ
  ob_start();
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();
    // 上記ループの出力を変数に代入
    $posts_html .= ob_get_clean();
  } else {
    $posts_html = '<p>該当する投稿はありませんでした。</p>';
  }
  $return = array(
    'max_page' => $max_num_pages,
    'content' => $posts_html
  );
  // JSON レスポンスを返す(JSON に変換して出力)
  wp_send_json($return);
}
// AJAX アクションの登録
add_action('wp_ajax_page_filter', 'my_ajax_page_handler');
add_action('wp_ajax_nopriv_page_filter', 'my_ajax_page_handler');

JavaScript(AJAX)

以下は jQuery を使った AJAX の記述です。

追加読み込みのボタンをクリックして送信するリクエストはその時点での抽出条件とページ番号に依存するので、フィルターが変更されたらページ番号を初期化し、追加読み込みのボタンを非表示にしています。

リセットボタンをクリックした際の処理は、セレクトボックスの値を初期値に戻し、キーワードの入力欄をクリアします。また、以下では表示内容も初期状態に戻しています。

js/ajax-page-filter.js(jQuery)
jQuery(function($){
  // フォーム
  const filterForm = $('#ajax-page-filter');
  // フォームのボタン
  const applyBtn = filterForm.find('button.apply-filter');
  // フォームのリセットボタン
  const resetBtn = filterForm.find('button.reset-filter');
  // 投稿出力先
  const filterPosts = $('#filter-posts');
  // 追加読み込みのボタン
  const loadMoreBtn = $('#load-more');
  // ページ番号を設定した input 要素
  const inputPaged =  $('[name="paged"]');

  // フィルターが変更されたら
  filterForm.change(function(){
    // ページ番号を初期化
    inputPaged.val(1);
    // 追加読み込みのボタンを非表示に
    loadMoreBtn.hide();
  });

  // フォームの submit イベント
  filterForm.submit(function(){
    // ページ番号を初期化
    inputPaged.val(1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filterForm.attr('action'),
      // フォームデータをシリアライズ
      data: filterForm.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filterForm.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        applyBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // ボタンのテキストを戻す
      applyBtn.text('適用');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      filterPosts.html(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        loadMoreBtn.hide();
      } else {
        // ページ総数が2以上の場合は追加読み込みボタンを表示
        loadMoreBtn.show();
      }
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      applyBtn.text('適用');
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      filterPosts.text('フィルタリングでエラーが発生しました。');
    });
    // ブラウザのデフォルトの動作をキャンセル
    return false;
  });

  // リセットボタンをクリックしたらフィルターの項目をリセット
  resetBtn.click(function(){
    const selectElems = filterForm.find('select');
    // セレクトボックスの選択をリセット
    selectElems.each(function() {
      $(this).val($(this).children('option').first().val());
    });
    const searchInput = filterForm.find('input[name="search');
    // キーワードをリセット
    searchInput.val('');
    // 表示結果も初期状態に戻す
    applyBtn.click();
    // 初期状態に戻さない場合は以下を実行
    //inputPaged.value = 1;
    //loadMoreBtn.style.display = 'none';
  });

  // 追加読み込みボタンの click イベント
  loadMoreBtn.click(function(){

    // ページ番号を1増加
    inputPaged.val(parseInt(inputPaged.val()) + 1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filterForm.attr('action'),
      // フォームデータをシリアライズ
      data: filterForm.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filterForm.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        loadMoreBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // ボタンのテキストを戻す
      loadMoreBtn.text('もっと読み込む');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      filterPosts.append(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        loadMoreBtn.hide();
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(inputPaged.val())  === data.max_page) {
          loadMoreBtn.hide();
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          loadMoreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      loadMoreBtn.text('もっと読み込む');
      console.warn(`追加読み込み失敗: ${error.status} ${error.statusText}` );
      filterPosts.text('追加読み込みでエラーが発生しました。');
    });
  });
});

以下は上記を Fetch API で書き換えたコードです。

この例では全ての処理をまとめて関数に定義して DOMContentLoaded イベントを使って呼び出しています。このように処理を関数にすることでグローバル変数を減らすことができます。

js/ajax-page-filter.js(Fetch API )
document.addEventListener('DOMContentLoaded', () => {
  setupAjaxPageFilter();
});

function setupAjaxPageFilter() {
  // フォーム
  const filterForm = document.getElementById('ajax-page-filter');
  // フォームのボタン
  const applyBtn = document.querySelector('#ajax-page-filter button.apply-filter');
  // フォームのリセットボタン
  const resetBtn = document.querySelector('#ajax-page-filter button.reset-filter');
  // 投稿出力先
  const filterPosts = document.getElementById('filter-posts');
  // 追加読み込みのボタン
  const loadMoreBtn = document.getElementById('load-more');
  // ページ番号を設定した input 要素
  const inputPaged = document.querySelector('[name="paged"]');

  if(filterForm){

    // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
    const beforeSend = (elem) =>{
      // 要素のテキストを変更
      elem.textContent = '取得中...';
    }

    // フィルターが変更されたら
    filterForm.addEventListener('change', () => {
      // ページ番号を初期化
      inputPaged.value = 1;
      // 追加読み込みのボタンを非表示に
      loadMoreBtn.style.display = 'none';
    })

    // フォームの submit イベント
    filterForm.addEventListener('submit', (e) => {
      e.preventDefault();

      // ボタンのテキストを変更
      beforeSend(applyBtn);

      // ページ番号を1にリセット
      inputPaged.value = 1;

      fetch( filterForm.getAttribute('action'), {
        method: filterForm.getAttribute('method'),
        body: new FormData(filterForm)
      }).then((response) => {
        if(response.ok) {
          //  レスポンスの json() メソッドで JSON として解析
          return response.json();
        } else {
          applyBtn.textContent = '適用';
          filterPosts.textContent = 'フィルタリングでエラーが発生しました。';
          throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
        }
      })
      .then((data) => {
        applyBtn.textContent = '適用';
        // data は JSON オブジェクト(出力する HTML は data.content)
        filterPosts.innerHTML = data.content;
        // ページ総数が2未満の場合は追加読み込みボタンは非表示
        if ( data.max_page < 2 ) {
          loadMoreBtn.style.display = 'none';
        } else {
          // ページ総数が2以上の場合は追加読み込みボタンを表示
          loadMoreBtn.style.display = 'block';
        }
      })
      .catch((error) => {
        console.warn(error);
      });
    });

    // リセットボタンをクリックしたらフィルターの項目をリセット
    resetBtn.addEventListener('click', ()=> {
      const selectElems = filterForm.querySelectorAll('select');
      // セレクトボックスの選択をリセット
      selectElems.forEach( select => {
        // キーワードをリセット
        select.value = select.querySelector('option').value;
      });
      const searchInput = filterForm.querySelector('input[name="search"]');
      searchInput.value = '';
      // 表示結果も初期状態に戻す
      applyBtn.click();
      // 初期状態に戻さない場合は以下を実行
      //inputPaged.value = 1;
      //loadMoreBtn.style.display = 'none';
    });

    // 追加読み込みボタンの click イベント
    loadMoreBtn.addEventListener('click', () => {
      // ページ番号を1増加
      inputPaged.value = parseInt(inputPaged.value) + 1;

      // ボタンのテキストを変更
      beforeSend(loadMoreBtn);

      fetch( filterForm.getAttribute('action'), {
        method: filterForm.getAttribute('method'),
        body: new FormData(filterForm)
      }).then((response) => {
        if(response.ok) {
          //  レスポンスの json() メソッドで JSON として解析
          return response.json();
        } else {
          loadMoreBtn.textContent = 'もっと読み込む';
          filterPosts.textContent = '追加読み込みでエラーが発生しました。';
          throw new Error(`追加読み込み失敗:${response.status} ${response.statusText}`);
        }
      })
      .then((data) => {
        loadMoreBtn.textContent = 'もっと読み込む';
        // レスポンスを挿入(既存の内容にレスポンスを追加)
        filterPosts.insertAdjacentHTML('beforeend', data.content);
        // ページ総数が2未満の場合は追加読み込みボタンは非表示
        if ( data.max_page < 2 ) {
          loadMoreBtn.style.display = 'none';
        } else {
          // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
          if (parseInt(inputPaged.value)  === data.max_page) {
            loadMoreBtn.style.display = 'none';
          }else {
            // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
            loadMoreBtn.style.display = 'blaock';
          }
        }
      })
      .catch((error) => {
        console.warn(error);
      });
    });
  }
}
js/ajax-page-filter.js(XMLHttpRequest)
document.addEventListener("DOMContentLoaded", () => {
  setupAjaxPageFilter();
});

function setupAjaxPageFilter() {
  // フォーム
  const filterForm = document.getElementById("ajax-page-filter");
  // フォームのボタン
  const applyBtn = document.querySelector("#ajax-page-filter button.apply-filter");
  // フォームのリセットボタン
  const resetBtn = document.querySelector("#ajax-page-filter button.reset-filter");
  // 投稿出力先
  const filterPosts = document.getElementById("filter-posts");
  // 追加読み込みのボタン
  const loadMoreBtn = document.getElementById("load-more");
  // ページ番号を設定した input 要素
  const inputPaged = document.querySelector('[name="paged"]');

  if (filterForm) {
    // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
    const beforeSend = (elem) => {
      // 要素のテキストを変更
      elem.textContent = "取得中...";
    };

    // フィルターが変更されたら
    filterForm.addEventListener("change", () => {
      // ページ番号を初期化
      inputPaged.value = 1;
      // 追加読み込みのボタンを非表示に
      loadMoreBtn.style.display = "none";
    });

    // フォームの submit イベント
    filterForm.addEventListener("submit", (e) => {
      e.preventDefault();

      // ボタンのテキストを変更
      beforeSend(applyBtn);

      // ページ番号を1にリセット
      inputPaged.value = 1;

      const xhr = new XMLHttpRequest();
      // responseType プロパティに 'json' を設定
      xhr.responseType = "json";
      xhr.open(filterForm.getAttribute("method"), filterForm.getAttribute("action"));
      xhr.addEventListener("readystatechange", () => {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 300) {
            applyBtn.textContent = '適用';
            // JSON として解析された受信データ(JSON オブジェクト)
            const data = xhr.response;
            filterPosts.innerHTML = data.content;
            // ページ総数が2未満の場合は追加読み込みボタンは非表示
            if (data.max_page < 2) {
              loadMoreBtn.style.display = 'none';
            } else {
              // ページ総数が2以上の場合は追加読み込みボタンを表示
              loadMoreBtn.style.display = 'block';
            }
          } else {
            filterPosts.textContent = "フィルタリングでエラーが発生しました。";
            console.log(
              `フィルタリング失敗: ${xhr.status} (${xhr.statusText})`
            );
          }
        }
      });
      xhr.send(new FormData(filterForm));
    });

    // リセットボタンをクリックしたらフィルターの項目をリセット
    resetBtn.addEventListener("click", () => {
      const selectElems = filterForm.querySelectorAll("select");
      // セレクトボックスの選択をリセット
      selectElems.forEach((select) => {
        // キーワードをリセット
        select.value = select.querySelector("option").value;
      });
      const searchInput = filterForm.querySelector('input[name="search"]');
      searchInput.value = "";
      // 表示結果も初期状態に戻す
      applyBtn.click();
      // 初期状態に戻さない場合は以下を実行
      //inputPaged.value = 1;
      //loadMoreBtn.style.display = 'none';
    });

    // 追加読み込みボタンの click イベント
    loadMoreBtn.addEventListener("click", () => {
      // ページ番号を1増加
      inputPaged.value = parseInt(inputPaged.value) + 1;

      // ボタンのテキストを変更
      beforeSend(loadMoreBtn);

      const xhr = new XMLHttpRequest();
      // responseType プロパティに 'json' を設定
      xhr.responseType = "json";
      xhr.open(filterForm.getAttribute("method"), filterForm.getAttribute("action"));
      xhr.addEventListener("readystatechange", () => {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 300) {
            // JSON として解析された受信データ(JSON オブジェクト)
            const data = xhr.response;
            loadMoreBtn.textContent = "もっと読み込む";
            // レスポンスを挿入(既存の内容にレスポンスを追加)
            filterPosts.insertAdjacentHTML("beforeend", data.content);
            // ページ総数が2未満の場合は追加読み込みボタンは非表示
            if (data.max_page < 2) {
              loadMoreBtn.style.display = "none";
            } else {
              // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
              if (parseInt(pagedInput.value) === data.max_page) {
                loadMoreBtn.style.display = "none";
              } else {
                // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
                loadMoreBtn.style.display = "blaock";
              }
            }
          } else {
            filterPosts.textContent = "追加読み込みでエラーが発生しました。";
            console.log(`追加読み込み失敗: ${xhr.status} (${xhr.statusText})`);
          }
        }
      });
      xhr.send(new FormData(filterForm));
    });
  }
}
change と input イベント

前述の例はフィルターを変更して「適用」ボタンをクリックして並べ替えを実行しますが、以下はフィルターを変更すると change や input イベントを使って自動的に並べかえを実行する例です。

「適用」ボタンは不要になるため削除します。

また、ボタンに表示していた「取得中...」という状態を表すテキストは p 要素を追加して表示しています。

template-parts/filter.php フィルター部分の抜粋
<form action="<?php echo admin_url('admin-ajax.php'); ?>" method="POST" id="ajax-page-filter">
  <?php
  if ($terms = get_terms(
    array(
      'taxonomy' => 'category',
      'orderby' => 'name'
    )
  )) :
    echo '<select name="category"><option value="">全てのカテゴリー</option>';
    foreach ($terms as $term) :
      echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
    endforeach;
    echo '</select>';
  endif;
  ?>
  <select name="per-page">
    <?php $per_page = get_option('posts_per_page'); ?>
    <option value="<?php echo esc_attr($per_page); ?>">件数:<?php echo esc_html($per_page); ?>件</option>
    <option value="10">10件</option>
    <option value="20">20件</option>
    <option value="-1">全件</option>
  </select>
  <select name="order">
    <option value="DESC">新しい順</option>
    <option value="ASC">古い順</option>
  </select>
  <input type="text" name="search" value="" placeholder="キーワード" size="10">
  <!-- ボタンを削除 <button class="apply-filter">適用</button> -->
  <button class="reset-filter" type="button">リセット</button>
  <input type="hidden" name="action" value="page_filter">
  <input type="hidden" name="_ajax_nonce" value="<?php echo wp_create_nonce('my-ajax-page-filter'); ?>">
  <input type="hidden" name="paged" value="1">
  <p id="ajax-status"></p><!-- 状態を表示する要素を追加 -->
</form>

以下が jQuery を使った AJAX の処理です。フィルターが変更された際に呼び出す AJAX 処理を関数に定義して、フォームの submit イベントの代わりに change イベントを使います。

但し、キーワード入力は input イベントで Debounce で拡張した関数を呼び出します。

追加読み込みの処理(ボタンの click イベント)は前述の例と同じです(変更はありません)。

jQuery(function($){

  //Debounce 関数
  const debounce = (func, timeout) => {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, timeout);
    }
  }

  // フォーム
  const filterForm = $('#ajax-page-filter');
  // 状態を表示する要素
  const status = $('#ajax-status');
  // フォームのリセットボタン
  const resetBtn = filterForm.find('button.reset-filter');
  // 投稿出力先
  const filterPosts = $('#filter-posts');
  // 追加読み込みのボタン
  const loadMoreBtn = $('#load-more');
  // ページ番号を設定した input 要素
  const inputPaged =  $('[name="paged"]');

  // フィルターが変更されたら
  filterForm.change(function(){
    // ページ番号を初期化
    inputPaged.val(1);
    // 追加読み込みのボタンを非表示に
    loadMoreBtn.hide();
  });

  // フィルターが変更された際に呼び出す AJAX 処理を関数に定義
  function ajaxCall() {
    // ページ番号を初期化
    inputPaged.val(1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filterForm.attr('action'),
      // フォームデータをシリアライズ
      data: filterForm.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filterForm.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // status のテキストを変更
        status.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // status のテキストを戻す
      status.text('');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      filterPosts.html(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        loadMoreBtn.hide();
      } else {
        // ページ総数が2以上の場合は追加読み込みボタンを表示
        loadMoreBtn.show();
      }
    })
    .fail( function(error) { //失敗時の処理
      // status のテキストを変更
      status.text('');
      console.warn(`フィルタリング失敗: ${error.status} ${error.statusText}` );
      filterPosts.text('フィルタリングでエラーが発生しました。');
    });
  }

  // フォームの change イベント
  filterForm.change(function(e){
    // キーワード絞り込み以外の場合は AJAX 処理を呼び出す
    if(e.target.name !== 'search') {
      ajaxCall();
    }
  });

  // AJAX の呼び出しの関数を Debounce 関数でラップ
  const debouncedAjaxCall = debounce(ajaxCall, 600);

  // キーワード入力は input イベントで Debounce 関数を呼び出す
  $('[name="search"]').on('input', function() {
    // Debounce を適用した AJAX を呼び出す
    debouncedAjaxCall(false);
  })

  // フォームの submit イベント
  filterForm.submit(function () {
    // デフォルトの動作をキャンセル
    return false;
  });

  // リセットボタンをクリックしたらフィルターの項目をリセット
  resetBtn.click(function(){
    const selectElems = filterForm.find('select');
    // セレクトボックスの選択をリセット
    selectElems.each(function() {
      $(this).val($(this).children('option').first().val());
    });
    const searchInput = filterForm.find('input[name="search');
    // キーワードをリセット
    searchInput.val('');
    // 表示結果も初期状態に戻す
    ajaxCall();
  });

  // 追加読み込みボタンの click イベント
  loadMoreBtn.click(function(){

    // ページ番号を1増加
    inputPaged.val(parseInt(inputPaged.val()) + 1);

    $.ajax({
      // 送信先はフォームの action 属性を参照
      url: filterForm.attr('action'),
      // フォームデータをシリアライズ
      data: filterForm.serialize(),
      // 使用する HTTP メソッドはフォームの method 属性を参照(POST)
      type: filterForm.attr('method'),
      // 受信するデータの形式に json を指定
      dataType: 'json',
      // 送信する前の処理
      beforeSend: function(){
        // ボタンのテキストを変更
        loadMoreBtn.text('取得中...');
      }
    })
    .done( function( data ) { //成功した時の処理
      // ボタンのテキストを戻す
      loadMoreBtn.text('もっと読み込む');
      // レスポンスを挿入(既存の内容をレスポンスで書き換え)
      filterPosts.append(data.content);
      // ページ総数が2未満の場合は追加読み込みボタンは非表示
      if ( data.max_page < 2 ) {
        loadMoreBtn.hide();
      } else {
        // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
        if (parseInt(inputPaged.val())  === data.max_page) {
          loadMoreBtn.hide();
        }else {
          // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
          loadMoreBtn.show();
        }
      }
    })
    .fail( function(error) { //失敗時の処理
      // ボタンのテキストを変更
      loadMoreBtn.text('もっと読み込む');
      console.warn(`追加読み込み失敗: ${error.status} ${error.statusText}` );
      filterPosts.text('追加読み込みでエラーが発生しました。');
    });
  });
});
document.addEventListener('DOMContentLoaded', () => {
  setupAjaxPageFilter();
});

function setupAjaxPageFilter() {
  // フォーム
  const filterForm = document.getElementById('ajax-page-filter');
  // 状態を表示する要素
  const status = document.getElementById('ajax-status');
  // フォームのリセットボタン
  const resetBtn = document.querySelector('#ajax-page-filter button.reset-filter');
  // 投稿出力先
  const filterPosts = document.getElementById('filter-posts');
  // 追加読み込みのボタン
  const loadMoreBtn = document.getElementById('load-more');
  // ページ番号を設定した input 要素
  const inputPaged = document.querySelector('[name="paged"]');

  //Debounce 関数
  const debounce = (func, timeout) => {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, timeout);
    }
  }

  if(filterForm){

    // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
    const beforeSend = (elem) =>{
      // 要素のテキストを変更
      elem.textContent = '取得中...';
    }

    // フィルターが変更されたら
    filterForm.addEventListener('change', () => {
      // ページ番号を初期化
      inputPaged.value = 1;
      // 追加読み込みのボタンを非表示に
      loadMoreBtn.style.display = 'none';
    });

    const ajaxCall = () => {
      // status のテキストを変更
      beforeSend(status);

      // ページ番号を1にリセット
      inputPaged.value = 1;

      fetch( filterForm.getAttribute('action'), {
        method: filterForm.getAttribute('method'),
        body: new FormData(filterForm)
      }).then((response) => {
        if(response.ok) {
          //  レスポンスの json() メソッドで JSON として解析
          return response.json();
        } else {
          status.textContent = '';
          filterPosts.textContent = 'フィルタリングでエラーが発生しました。';
          throw new Error(`フィルタリング失敗:${response.status} ${response.statusText}`);
        }
      })
      .then((data) => {
        status.textContent = '';
        // data は JSON オブジェクト(出力する HTML は data.content)
        filterPosts.innerHTML = data.content;
        // ページ総数が2未満の場合は追加読み込みボタンは非表示
        if ( data.max_page < 2 ) {
          loadMoreBtn.style.display = 'none';
        } else {
          // ページ総数が2以上の場合は追加読み込みボタンを表示
          loadMoreBtn.style.display = 'block';
        }
      })
      .catch((error) => {
        console.warn(error);
      });
    }

    // フォームの change イベント
    filterForm.addEventListener('change', (e) => {
      // キーワード絞り込み以外の場合は AJAX 処理を呼び出す
      if(e.target.name !== 'search') {
        ajaxCall();
      }
    });

    // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
    const debouncedAjaxCall = debounce(ajaxCall, 600);

    // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
    filterForm.querySelector('[name="search"]').addEventListener('input', ()=> {
      debouncedAjaxCall();
    });

    filterForm.addEventListener('submit', (e)=> {
      // デフォルト動作のフォーム送信をキャンセル
      e.preventDefault();
    });

    // リセットボタンをクリックしたらフィルターの項目をリセット
    resetBtn.addEventListener('click', ()=> {
      const selectElems = filterForm.querySelectorAll('select');
      // セレクトボックスの選択をリセット
      selectElems.forEach( select => {
        // キーワードをリセット
        select.value = select.querySelector('option').value;
      });
      const searchInput = filterForm.querySelector('input[name="search"]');
      searchInput.value = '';
      // 表示結果も初期状態に戻す
      ajaxCall();
    });

    // 追加読み込みボタンの click イベント
    loadMoreBtn.addEventListener('click', () => {
      // ページ番号を1増加
      inputPaged.value = parseInt(inputPaged.value) + 1;

      // ボタンのテキストを変更
      beforeSend(loadMoreBtn);

      fetch( filterForm.getAttribute('action'), {
        method: filterForm.getAttribute('method'),
        body: new FormData(filterForm)
      }).then((response) => {
        if(response.ok) {
          //  レスポンスの json() メソッドで JSON として解析
          return response.json();
        } else {
          loadMoreBtn.textContent = 'もっと読み込む';
          filterPosts.textContent = '追加読み込みでエラーが発生しました。';
          throw new Error(`追加読み込み失敗:${response.status} ${response.statusText}`);
        }
      })
      .then((data) => {
        loadMoreBtn.textContent = 'もっと読み込む';
        // レスポンスを挿入(既存の内容にレスポンスを追加)
        filterPosts.insertAdjacentHTML('beforeend', data.content);
        // ページ総数が2未満の場合は追加読み込みボタンは非表示
        if ( data.max_page < 2 ) {
          loadMoreBtn.style.display = 'none';
        } else {
          // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
          if (parseInt(inputPaged.value)  === data.max_page) {
            loadMoreBtn.style.display = 'none';
          }else {
            // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
            loadMoreBtn.style.display = 'blaock';
          }
        }
      })
      .catch((error) => {
        console.warn(error);
      });
    });
  }
}
document.addEventListener('DOMContentLoaded', () => {
  setupAjaxPageFilter();
});

function setupAjaxPageFilter() {
  // フォーム
  const filterForm = document.getElementById('ajax-page-filter');
  // 状態を表示する要素
  const status = document.getElementById('ajax-status');
  // フォームのリセットボタン
  const resetBtn = document.querySelector('#ajax-page-filter button.reset-filter');
  // 投稿出力先
  const filterPosts = document.getElementById('filter-posts');
  // 追加読み込みのボタン
  const loadMoreBtn = document.getElementById('load-more');
  // ページ番号を設定した input 要素
  const inputPaged = document.querySelector('[name="paged"]');

  //Debounce 関数
  const debounce = (func, timeout) => {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        func.apply(this, args);
      }, timeout);
    }
  }

  if(filterForm){

    // リクエストを送信する前に実行する処理(jQuery の beforeSend に相当)
    const beforeSend = (elem) =>{
      // 要素のテキストを変更
      elem.textContent = '取得中...';
    }

    // フィルターが変更されたら
    filterForm.addEventListener('change', () => {
      // ページ番号を初期化
      inputPaged.value = 1;
      // 追加読み込みのボタンを非表示に
      loadMoreBtn.style.display = 'none';
    });

    const ajaxCall = () => {
      // status のテキストを変更
      beforeSend(status);

      // ページ番号を1にリセット
      inputPaged.value = 1;

      const xhr = new XMLHttpRequest();
      // responseType プロパティに 'json' を設定
      xhr.responseType = "json";
      xhr.open(filterForm.getAttribute("method"), filterForm.getAttribute("action"));
      xhr.addEventListener("readystatechange", () => {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 300) {
            status.textContent = '適用';
            // JSON として解析された受信データ(JSON オブジェクト)
            const data = xhr.response;
            filterPosts.innerHTML = data.content;
            // ページ総数が2未満の場合は追加読み込みボタンは非表示
            if (data.max_page < 2) {
              loadMoreBtn.style.display = 'none';
            } else {
              // ページ総数が2以上の場合は追加読み込みボタンを表示
              loadMoreBtn.style.display = 'block';
            }
          } else {
            status.textContent = '';
            filterPosts.textContent = "フィルタリングでエラーが発生しました。";
            console.log(
              `フィルタリング失敗: ${xhr.status} (${xhr.statusText})`
            );
          }
        }
      });
      xhr.send(new FormData(filterForm));
    }

    // フォームの change イベント
    filterForm.addEventListener('change', (e) => {
      // キーワード絞り込み以外の場合は AJAX 処理を呼び出す
      if(e.target.name !== 'search') {
        ajaxCall();
      }
    });

    // AJAX の呼び出しの関数を Debounce 関数でラップ(拡張)
    const debouncedAjaxCall = debounce(ajaxCall, 600);

    // キーワード入力は input イベントで Debounce で拡張した関数を呼び出す
    filterForm.querySelector('[name="search"]').addEventListener('input', ()=> {
      debouncedAjaxCall();
    });

    filterForm.addEventListener('submit', (e)=> {
      // デフォルト動作のフォーム送信をキャンセル
      e.preventDefault();
    });

    // リセットボタンをクリックしたらフィルターの項目をリセット
    resetBtn.addEventListener('click', ()=> {
      const selectElems = filterForm.querySelectorAll('select');
      // セレクトボックスの選択をリセット
      selectElems.forEach( select => {
        // キーワードをリセット
        select.value = select.querySelector('option').value;
      });
      const searchInput = filterForm.querySelector('input[name="search"]');
      searchInput.value = '';
      // 表示結果も初期状態に戻す
      ajaxCall();
    });

    // 追加読み込みボタンの click イベント
    loadMoreBtn.addEventListener('click', () => {
      // ページ番号を1増加
      inputPaged.value = parseInt(inputPaged.value) + 1;

      // ボタンのテキストを変更
      beforeSend(loadMoreBtn);

      const xhr = new XMLHttpRequest();
      // responseType プロパティに 'json' を設定
      xhr.responseType = "json";
      xhr.open(filterForm.getAttribute("method"), filterForm.getAttribute("action"));
      xhr.addEventListener("readystatechange", () => {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 300) {
            // JSON として解析された受信データ(JSON オブジェクト)
            const data = xhr.response;
            loadMoreBtn.textContent = "もっと読み込む";
            // レスポンスを挿入(既存の内容にレスポンスを追加)
            filterPosts.insertAdjacentHTML("beforeend", data.content);
            // ページ総数が2未満の場合は追加読み込みボタンは非表示
            if (data.max_page < 2) {
              loadMoreBtn.style.display = "none";
            } else {
              // ページ番号とページ総数が同じ場合は追加読み込みボタンは非表示
              if (parseInt(pagedInput.value) === data.max_page) {
                loadMoreBtn.style.display = "none";
              } else {
                // 上記以外でページ総数が2以上の場合は追加読み込みボタンを表示
                loadMoreBtn.style.display = "blaock";
              }
            }
          } else {
            filterPosts.textContent = "追加読み込みでエラーが発生しました。";
            console.log(`追加読み込み失敗: ${xhr.status} (${xhr.statusText})`);
          }
        }
      });
      xhr.send(new FormData(filterForm));
    });
  }
}

サーバー側の処理は前述の例と同じです。

function my_enqueue_ajax_page_scripts() {
  // AJAX を記述する JavaScript ファイルの登録
  wp_enqueue_script(
    'ajax-page-filter-script', // ハンドル名
    get_theme_file_uri('/js/ajax-page-filter.js'),
    array('jquery'), // jQuery を使わない場合は空の配列 array() を指定
    filemtime(get_theme_file_path('/js/ajax-page-filter.js')),
    true
  );
}
// 上記関数を wp_enqueue_scripts アクションにフック
add_action('wp_enqueue_scripts', 'my_enqueue_ajax_page_scripts');

// AJAX ハンドラの定義
function my_ajax_page_handler() {
  // nonce の値を検証
  check_ajax_referer('my-ajax-page-filter');

  // WP_Query に指定するパラメータ
  $args = array(
    'orderby' => 'date',
    'post_status' => 'publish',
    // リクエストの $_POST['paged'](ページ番号)を paged に指定
    'paged' => filter_input(INPUT_POST, 'paged', FILTER_VALIDATE_INT),
    // 投稿タイプを指定(必要に応じて変更)
    'post_type' => 'post',
    // 表示件数のパラメータを追加
    'posts_per_page' => filter_input(INPUT_POST, 'per-page', FILTER_VALIDATE_INT),
    // 表示順(order に DESC または ASC を指定)
    'order' => esc_html($_POST['order']),
    // 絞り込みキーワード(検索パラメータ s に入力値を指定)
    's' => esc_html($_POST['search'])
  );
  // カテゴリーが選択されていれば $args に tax_query を追加
  if (isset($_POST['category']) && $_POST['category'] !== '') {
    $args['tax_query'] = array(
      array(
        'taxonomy' => 'category',
        'field' => 'term_id',
        'terms' => esc_html($_POST['category'])
      )
    );
  }
  //クエリオブジェクトを生成
  $query = new WP_Query($args);
  // ページの合計数を生成した WP_Query から取得
  $max_num_pages = $query->max_num_pages;
  // 出力する HTML 文字列(レスポンス)の初期化
  $posts_html = '';
  // 出力をバッファ
  ob_start();
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      the_title('<h2><a href="' . esc_url(get_permalink()) . '">', '</a></h2>');
      the_excerpt();
    }
    wp_reset_postdata();
    // 上記ループの出力を変数に代入
    $posts_html .= ob_get_clean();
  } else {
    $posts_html = '<p>該当する投稿はありませんでした。</p>';
  }
  $return = array(
    'max_page' => $max_num_pages,
    'content' => $posts_html
  );
  // JSON レスポンスを返す(JSON に変換して出力)
  wp_send_json($return);
}
// AJAX アクションの登録
add_action('wp_ajax_page_filter', 'my_ajax_page_handler');
add_action('wp_ajax_nopriv_page_filter', 'my_ajax_page_handler');

参考サイト

以下のサイトを参考にさせていただきました。