star back image
people4
電飾 電飾
moon
astronaut

【WordPress】絞り込み(フィルタ)機能の実装キット

BLOG AIWEBログWordPress
読了約:69分

商品とかの一覧ページを作って、絞り込み(フィルタ)させたいです。
その簡単キットを作るのが目標です。すぐ忘れるので。

普通のブログ記事以外で、商品一覧のようないろんなジャンルのものを表示させて、ボタンで絞り込み(フィルタ)したい。よくあるやつです。

今回の題材は「建築系コーポレートサイトの実績一覧ページ」です。
簡単に言いますと以下の3つがほしいです。

  • 公開側: /works/(内容で絞り込む一覧ページ)
  • 公開側: /works/パーマリンク/(詳細内容ページ)
  • 管理画面: /wp-admin/edit.php?post_type=works

この記事の内容は以下のような感じの流れです。

記事にした理由(毎回忘れる)

ワイヤーに「事業で絞り込む」「施設の種類で絞り込む」とチップが並んでいます。
見た目はボタンのオンオフです。CSSとJSだけでやれそうに見えますよね。

WordPressで管理して、2軸で絞り込みをさせたいです。

ワイヤーから推測をしているのですね。

ここで手が止まるのです。それでうまくいく?
以下の理由から気付きます。

  • チップの文言は、あとから増える
  • 1件の実績に、事業も施設も付く
  • 「電気設備工事」かつ「オフィス」のように、2軸を同時に絞る
  • 9件ずつ「さらに読み込む」があるので、件数とページングを出す
  • JSが落ちても、URLを共有しても同じ結果にしたい

見た目ではなくデータの持ち方です。

「並び替え(ソート)」ではなく、絞り込み(フィルタ)です。
(並びは投稿日のまま)URLにパラメータをつけて出す集合を変えます

Cardの最下部には「さらに読み込む」ボタンがあります。

用語画面で言うとWordPressでの名前
チップ一覧上部の絞り込みボタンフロントのリンク(a要素)
ターム投稿編集の右サイドのチェック1つterm
タクソノミ「事業内容」「建物の種類」という分類の箱taxonomy

WordPressなら答えはだいたいこれです。

  1. カスタム投稿タイプ works
  2. タクソノミを2本(事業/建物)
  3. 管理画面で投稿に付ける
  4. 一覧は tax_query で取る
  5. 見た目のチップはターム一覧から描く

一覧の絞り込み「オフィス」「電気設備工事」はボタンです。
実装では、管理画面で実績に付けた分類を、一覧の絞り込みに使っています。見た目の名前はチップ、データの名前はターム、分類の仕組みはタクソノミです。この記事では、管理画面のどこがそれに当たるかを先に示します。

専門用語が盛りだくさんです( ºωº; )ヌヌ…

こういう機能は以前に何回もやっていますが覚えていません (´•ω•`)

仕様を先に決める

記事を見てくれる人向けの、なんとなく理解用の表です。

項目決め
投稿タイプworks(公開アーカイブ /works/
軸1タクソノミ works_service(管理画面名: 事業内容)
軸2タクソノミ works_building(管理画面名: 建物の種類)
選択各軸単一。未選択は all
2軸の関係AND
URL/works/?works_service=スラッグ&works_building=スラッグall はパラメータを付けない)
JSオフリンクのまま通常遷移
JSオンクリックでAJAX。history.replaceState でURLだけ更新(スクロール位置を維持)
1ページ9件。フィルタ後も同じ
管理画面一覧カラムに事業内容・建物の種類を出す(show_admin_column

以下の絞り込みのチップのラベルボタンを押すと。。

  • 事業内容で絞り込む
  • 建物の種類で絞り込む

バキバキとカードが入れ替わるイメージです。

AIとの相談とプロンプト

geminiでもChatGPTでもどちらでも良いです。
こんな感じで作りたい。ワイヤーを見てもらいながら出来たプロンプトは以下です。

AIさんに聞きました(( ;`ω´)ゴクリ

プロンプト1(方針)
WordPressテーマで、実績一覧 /works/ の絞り込みを実装する。
ソートではない。フィルタ。

見た目はチップが2行。
・事業内容で絞り込む(すべて + ターム)
・建物の種類で絞り込む(すべて + ターム)
各行は単一選択。2行は同時に効く(AND)。
お問い合わせフォームの複数選択とは別物。

カスタム投稿 works にタクソノミ works_service / works_building を付ける。
hierarchical は true(投稿編集はチェックリスト)。
show_admin_column は true。
supports は title, editor, thumbnail, excerpt。
管理画面からタームを増やせるようにする。
既定タームは init で term_exists を見て投入。

JS無しでも <a href> のクエリで動く。
JSありのときはAJAXで一覧だけ差し替え、history.replaceState でURLを更新する。
1ページ9件。「さらに読み込む」があるので display:none で隠さない。
関数・enqueue・nonce の接頭辞は corp_ / corpWorks に統一する。
コードコメントは日本語。
プロンプト2(ファイル配置)
テーマ直下に置く。子テーマならファイルも functions.php も子に置き、
パスは get_stylesheet_directory() / get_stylesheet_directory_uri() を使う。
親テーマだけなら get_template_directory() でよい。

functions.php から inc/cpt-works.php を require する。
CPT・タクソノミ・クエリ・AJAXは inc/cpt-works.php。
一覧は archive-works.php。
詳細は single-works.php(ブログのカテゴリーを使わない。タームは絞り込みURLへリンク)。
カードは template-parts/works/card.php。
JSは assets/js/works.js(一覧のみ enqueue)。
CSSは assets/css/works.css(一覧と詳細で enqueue)。
nonce は wp_localize_script。AJAXの action 名と揃える。
初回だけ flush_rewrite_rules。
CPT UI で同じスラッグ works を作らない。

ポイントは「ソートして」と書かないことと、「複数選択にしない」と先に書くことです。
ここを曖昧にすると、配列の terms や operator => IN の話が先に出てきます。

プロンプト3(詳細と本文)
実績一覧の絞り込みは実装済み。ここから詳細を足す。

1. CPT works の supports に editor を追加する(title, editor, thumbnail, excerpt)。
2. テーマ直下に single-works.php を作る。ブログの single.php は使わない。
   投稿カテゴリー(get_the_category)は出さない。空の <a href=""> を作らない。
3. 詳細に出す分類は、タクソノミ works_service / works_building のターム。
   リンク先は一覧の絞り込みURL(corp_works_filter_url)にする。
4. 詳細の中身はアイキャッチ、タイトル、ターム、the_content()、実績一覧へ戻る。
   パンくずは HOME › 実績 › タイトル。
5. CSS は一覧と詳細の両方で enqueue する。JS(AJAX)は一覧だけ。
6. 関数・nonce・JS変数の接頭辞は corp_ / corpWorks のまま。新規の別接頭辞は作らない。
7. ファイルを子テーマに置いている場合は get_stylesheet_directory() を使う。
   親テーマだけの場合は get_template_directory() のまま。

コードコメントは日本語。既存の inc/cpt-works.php を壊さない。

「プロンプトは方針です。」
AIさんから、そう助言をいただきました。

相談ばかりで、あなた何もしてませんね。

作業の流れと出力コード

作成してもらったプロンプトをCursorで実行すると、以下のファイルが作成されました。

出力サンプルページ
https://neo.astrowave.jp/works/

この記事で使ったファイルは、以下からダウンロードできます。

【共有】ファイル一式
https://astrowave.jp/amnesia_record/works-filter-sample.zip

テーマ例: wp-content/themes/_works-filter-sample/

_works-filter-sample/
├── functions-snippet.php ← functions.php に追記する断片
├── archive-works.php ← 一覧テンプレート
├── single-works.php ← 詳細テンプレート
├── inc/
│ └── cpt-works.php ← 実績CPT・タクソノミ・絞り込みクエリ・AJAX
├── template-parts/
│ └── works/
│ └── card.php ← カード1枚
├── assets/
│ ├── css/
│ │ └── works.css
│ └── js/
│ └── works.js ← 「さらに読み込む」とAJAX絞り込み


※CPT UIプラグインは使いません。インストール済みでも、投稿タイプやタクソノミがworks / works_service / works_building が無ければ(被ってなければ)OKです。

1. functions.php で読む・読み込む

<?php
/**
 * functions.php に追記する断片(サンプル)
 * テーマの既存 functions.php にコピーしてください。
 */

require_once get_template_directory() . '/inc/cpt-works.php';

add_action(
	'wp_enqueue_scripts',
	function () {
		if ( ! is_post_type_archive( 'works' ) && ! is_singular( 'works' ) ) {
			return;
		}

		wp_enqueue_style(
			'corp-works',
			get_template_directory_uri() . '/assets/css/works.css',
			array(),
			'1.0.1'
		);

		if ( ! is_post_type_archive( 'works' ) ) {
			return;
		}

		wp_enqueue_script(
			'corp-works',
			get_template_directory_uri() . '/assets/js/works.js',
			array(),
			'1.0.1',
			true
		);

		wp_localize_script(
			'corp-works',
			'corpWorks',
			array(
				'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
				'nonce'      => wp_create_nonce( 'corp_works_filter' ),
				'archiveUrl' => get_post_type_archive_link( 'works' ),
			)
		);
	}
);

実績ページだけ CSS/JS を出します。nonce のアクション名は、あとで AJAX 側と揃えます。既存テーマの共通CSSがあるなら、第3引数の array() にそのハンドルを入れてください。

子テーマにしている人はWordPressでは次の違いが

  • get_stylesheet_directory() … 今使っているテーマ(子がいれば子)
  • get_template_directory() … 親テーマ(子が無ければ同じ場所)

上のコードは親テーマの表記です。
状況に合わせて、差し替えてください。

2. CPT とタクソノミ(ここが管理画面)

inc/cpt-works.php の核です。

<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

function corp_register_works() {
	register_post_type(
		'works',
		array(
			'labels'        => array(
				'name'          => '実績',
				'singular_name' => '実績',
				'add_new_item'  => '実績を追加',
				'edit_item'     => '実績を編集',
				'all_items'     => '実績一覧',
				'menu_name'     => '実績',
			),
			'public'        => true,
			'has_archive'   => true,
			'rewrite'       => array(
				'slug'       => 'works',
				'with_front' => false,
			),
			'menu_icon'     => 'dashicons-portfolio',
			'supports'      => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
			'show_in_rest'  => true,
		)
	);

	register_taxonomy(
		'works_service',
		'works',
		array(
			'labels'            => array(
				'name'          => '事業内容',
				'singular_name' => '事業内容',
				'menu_name'     => '事業内容',
				'add_new_item'  => '事業内容を追加',
			),
			'public'            => true,
			'hierarchical'      => true,
			'show_admin_column' => true,
			'show_in_rest'      => true,
			'rewrite'           => array(
				'slug'       => 'works-service',
				'with_front' => false,
			),
		)
	);

	register_taxonomy(
		'works_building',
		'works',
		array(
			'labels'            => array(
				'name'          => '建物の種類',
				'singular_name' => '建物の種類',
				'menu_name'     => '建物の種類',
				'add_new_item'  => '建物の種類を追加',
			),
			'public'            => true,
			'hierarchical'      => true,
			'show_admin_column' => true,
			'show_in_rest'      => true,
			'rewrite'           => array(
				'slug'       => 'works-building',
				'with_front' => false,
			),
		)
	);
}
add_action( 'init', 'corp_register_works', 0 );

hierarchical => true にすると、投稿編集画面がカテゴリー型のチェックリストになります。実績は「複数の事業タグが付く」ことがあるので、投稿側は複数可、フロントのフィルタだけ単一、という分担です。

show_admin_column => true で、/wp-admin/edit.php?post_type=works の一覧に列が出ます。別途 manage_posts_columns を書かなくてよい、というのが地味に助かります。

※注意: プラグイン「Custom Post Type UI」で同じスラッグ works を作ると二重登録になります。テーマ側で持つなら、CPT UI では作らない。

登録したあと、管理画面の 設定 → パーマリンク を開いて「変更を保存」(中身は触らなくてよい)を一度押します。/works/ が 404 のときは、だいたいこれです。

テーマ内で初回だけ flush する場合:

function corp_works_maybe_flush_rewrite() {
	if ( get_option( 'corp_works_rewrite_flushed_v1' ) ) {
		return;
	}
	flush_rewrite_rules( false );
	update_option( 'corp_works_rewrite_flushed_v1', 1 );
}
add_action( 'init', 'corp_works_maybe_flush_rewrite', 99 );
パーマリンク保存の押し忘れ対策
/works/ をWordPressに覚えさせる処理です。初回だけ自動で行い、済んだらオプションに記録するので、毎回は走りません。404のときは設定 → パーマリンクで「変更を保存」でも同じです。

オプション名を変えない限り、毎回 flush しません。

3. 既定のチップ項目を投入する

フロントのチップは get_terms() で出します。タームが空だと「すべて」しか出ません。
初回だけ term_exists を見て入れます。

function corp_works_default_terms() {
	return array(
		'works_service'  => array(
			'建物総合管理',
			'電気設備工事',
			'空調・衛生設備工事',
			'建築工事',
			'内装工事',
			'保守・点検',
		),
		'works_building' => array(
			'教育施設',
			'マンション',
			'オフィス',
			'工場',
			'医療・福祉施設',
			'倉庫',
			'商業施設・店舗',
		),
	);
}

function corp_works_ensure_terms() {
	foreach ( corp_works_default_terms() as $taxonomy => $terms ) {
		foreach ( $terms as $name ) {
			if ( ! term_exists( $name, $taxonomy ) ) {
				wp_insert_term( $name, $taxonomy );
			}
		}
	}
}
add_action( 'init', 'corp_works_ensure_terms', 20 );

もしパン屋さんだったら?

揚げパン:カレーパン、ちくわパン、みたいな感じに自由に変えてください。

プレサンプル登録のようなものですな。

以降の追加は管理画面で足します。

  • 事業内容: /wp-admin/edit-tags.php?taxonomy=works_service&post_type=works
  • 建物の種類: /wp-admin/edit-tags.php?taxonomy=works_building&post_type=works

スラッグは日本語名から自動生成されます。フィルタの URL はスラッグを使うので、公開後にスラッグをむやみに変えない方が安全です。

実績を新規で追加してタームをチェックして公開すると。。

サンプルページ
https://neo.astrowave.jp/works/31303/

一覧ページ

4. メインクエリを GET で絞る

archive-works.php で別 WP_Query を書く方法もあります。
アーカイブテンプレを普通にループしたいので、pre_get_posts でメインクエリをいじります。

function corp_works_per_page() {
	return 9;
}

function corp_works_pre_get_posts( $query ) {
	if ( is_admin() || ! $query->is_main_query() ) {
		return;
	}
	if ( ! $query->is_post_type_archive( 'works' ) ) {
		return;
	}

	$query->set( 'posts_per_page', corp_works_per_page() );

	$service  = isset( $_GET['works_service'] ) ? sanitize_title( wp_unslash( $_GET['works_service'] ) ) : '';
	$building = isset( $_GET['works_building'] ) ? sanitize_title( wp_unslash( $_GET['works_building'] ) ) : '';

	$tax_query = array( 'relation' => 'AND' );

	if ( $service && 'all' !== $service ) {
		$tax_query[] = array(
			'taxonomy' => 'works_service',
			'field'    => 'slug',
			'terms'    => $service,
		);
	}
	if ( $building && 'all' !== $building ) {
		$tax_query[] = array(
			'taxonomy' => 'works_building',
			'field'    => 'slug',
			'terms'    => $building,
		);
	}

	if ( count( $tax_query ) > 1 ) {
		$query->set( 'tax_query', $tax_query );
	}
}
add_action( 'pre_get_posts', 'corp_works_pre_get_posts' );

count > 1 なのは、先頭の relation => AND だけだと空の tax_query になるためです。軸が1本だけのときも、配列は relation + 条件1件で count が 2 になります。

フィルタ用 URL:

function corp_works_filter_url( $service, $building ) {
	$args = array();
	if ( $service && 'all' !== $service ) {
		$args['works_service'] = $service;
	}
	if ( $building && 'all' !== $building ) {
		$args['works_building'] = $building;
	}
	$url = get_post_type_archive_link( 'works' );
	return $args ? add_query_arg( $args, $url ) : $url;
}

all を URL に載せないので、初期表示はきれいな /works/ のままです。

AJAX でも同じ条件を使うため、組み立ては関数にまとめておきます。

function corp_works_build_tax_query( $service, $building ) {
	$tax_query = array( 'relation' => 'AND' );

	if ( $service && 'all' !== $service ) {
		$tax_query[] = array(
			'taxonomy' => 'works_service',
			'field'    => 'slug',
			'terms'    => $service,
		);
	}
	if ( $building && 'all' !== $building ) {
		$tax_query[] = array(
			'taxonomy' => 'works_building',
			'field'    => 'slug',
			'terms'    => $building,
		);
	}

	return count( $tax_query ) > 1 ? $tax_query : array();
}

function corp_works_query_args( $service, $building, $paged = 1 ) {
	$args = array(
		'post_type'      => 'works',
		'post_status'    => 'publish',
		'posts_per_page' => corp_works_per_page(),
		'paged'          => max( 1, (int) $paged ),
	);
	$tax_query = corp_works_build_tax_query( $service, $building );
	if ( ! empty( $tax_query ) ) {
		$args['tax_query'] = $tax_query;
	}
	return $args;
}

5. アーカイブにチップを出す

archive-works.php
タームは hide_empty => false にしています。まだ投稿が付いていない項目も、ワイヤーどおり並べるためです。運用で「件数ゼロは隠す」なら true に変えます。

<?php
get_header();

$current_service  = isset( $_GET['works_service'] ) ? sanitize_title( wp_unslash( $_GET['works_service'] ) ) : 'all';
$current_building = isset( $_GET['works_building'] ) ? sanitize_title( wp_unslash( $_GET['works_building'] ) ) : 'all';

$service_terms  = get_terms( array( 'taxonomy' => 'works_service', 'hide_empty' => false ) );
$building_terms = get_terms( array( 'taxonomy' => 'works_building', 'hide_empty' => false ) );
?>

<section class="works-filter js-works-filter" aria-label="実績の絞り込み">
	<div class="works-filter__row">
		<p class="works-filter__heading">事業内容で絞り込む</p>
		<ul class="works-filter__list">
			<li>
				<a class="works-filter__btn js-works-filter-btn<?php echo 'all' === $current_service ? ' is-active' : ''; ?>"
					href="<?php echo esc_url( corp_works_filter_url( 'all', $current_building ) ); ?>"
					data-filter-axis="service"
					data-filter-value="all">すべて</a>
			</li>
			<?php if ( ! is_wp_error( $service_terms ) ) : ?>
				<?php foreach ( $service_terms as $term ) : ?>
					<li>
						<a class="works-filter__btn js-works-filter-btn<?php echo $current_service === $term->slug ? ' is-active' : ''; ?>"
							href="<?php echo esc_url( corp_works_filter_url( $term->slug, $current_building ) ); ?>"
							data-filter-axis="service"
							data-filter-value="<?php echo esc_attr( $term->slug ); ?>"><?php echo esc_html( $term->name ); ?></a>
					</li>
				<?php endforeach; ?>
			<?php endif; ?>
		</ul>
	</div>
	<div class="works-filter__row">
		<p class="works-filter__heading">建物の種類で絞り込む</p>
		<ul class="works-filter__list">
			<li>
				<a class="works-filter__btn js-works-filter-btn<?php echo 'all' === $current_building ? ' is-active' : ''; ?>"
					href="<?php echo esc_url( corp_works_filter_url( $current_service, 'all' ) ); ?>"
					data-filter-axis="building"
					data-filter-value="all">すべて</a>
			</li>
			<?php if ( ! is_wp_error( $building_terms ) ) : ?>
				<?php foreach ( $building_terms as $term ) : ?>
					<li>
						<a class="works-filter__btn js-works-filter-btn<?php echo $current_building === $term->slug ? ' is-active' : ''; ?>"
							href="<?php echo esc_url( corp_works_filter_url( $current_service, $term->slug ) ); ?>"
							data-filter-axis="building"
							data-filter-value="<?php echo esc_attr( $term->slug ); ?>"><?php echo esc_html( $term->name ); ?></a>
					</li>
				<?php endforeach; ?>
			<?php endif; ?>
		</ul>
	</div>
</section>

<section class="works-grid-section js-works-grid-section" aria-label="実績一覧" aria-live="polite">
	<?php if ( have_posts() ) : ?>
		<div class="works-grid">
			<?php
			while ( have_posts() ) :
				the_post();
				get_template_part( 'template-parts/works/card' );
			endwhile;
			?>
		</div>
	<?php else : ?>
		<p>該当する実績がありません。</p>
	<?php endif; ?>
</section>

チップはリンク(a要素)で出す。button にはしない。JS が preventDefault する前に、サーバー側の絞り込みが完成している状態を先に作ります。

事業を変えるリンクでも、いま選んでいる建物は corp_works_filter_url( $term->slug, $current_building ) で残します。片方を切り替えても、もう片方は維持。これが2軸フィルタの肝です。

6. カードにタームを出す

template-parts/works/card.php

<?php
$services  = get_the_terms( get_the_ID(), 'works_service' );
$buildings = get_the_terms( get_the_ID(), 'works_building' );
?>
<article <?php post_class( 'works-card' ); ?>>
	<h2 class="works-card__title"><?php the_title(); ?></h2>
	<?php if ( $services && ! is_wp_error( $services ) ) : ?>
		<ul class="works-card__tags">
			<?php foreach ( $services as $term ) : ?>
				<li><?php echo esc_html( $term->name ); ?></li>
			<?php endforeach; ?>
		</ul>
	<?php endif; ?>
	<?php if ( $buildings && ! is_wp_error( $buildings ) ) : ?>
		<ul class="works-card__tags">
			<?php foreach ( $buildings as $term ) : ?>
				<li><?php echo esc_html( $term->name ); ?></li>
			<?php endforeach; ?>
		</ul>
	<?php endif; ?>
</article>

選択中スラッグと同じタグだけ塗りつぶす。

7. AJAX(チップをクリックしても再読込しない)

サーバーはメインクエリと同じ corp_works_query_args() を使います。条件が2系統になると、必ずズレます。

function corp_works_request_filters() {
	$service  = isset( $_POST['works_service'] ) ? sanitize_title( wp_unslash( $_POST['works_service'] ) ) : 'all';
	$building = isset( $_POST['works_building'] ) ? sanitize_title( wp_unslash( $_POST['works_building'] ) ) : 'all';
	if ( '' === $service ) {
		$service = 'all';
	}
	if ( '' === $building ) {
		$building = 'all';
	}
	return array( $service, $building );
}

function corp_works_render_cards_html( $query ) {
	ob_start();
	if ( $query->have_posts() ) {
		echo '<div class="works-grid">';
		while ( $query->have_posts() ) {
			$query->the_post();
			get_template_part( 'template-parts/works/card' );
		}
		echo '</div>';
	} else {
		echo '<p>該当する実績がありません。</p>';
	}
	wp_reset_postdata();
	return ob_get_clean();
}

function corp_works_ajax_filter() {
	check_ajax_referer( 'corp_works_filter', 'nonce' );

	list( $service, $building ) = corp_works_request_filters();
	$query = new WP_Query( corp_works_query_args( $service, $building, 1 ) );

	wp_send_json_success(
		array(
			'gridHtml' => corp_works_render_cards_html( $query ),
			'url'      => corp_works_filter_url( $service, $building ),
			'service'  => $service,
			'building' => $building,
		)
	);
}
add_action( 'wp_ajax_corp_works_filter', 'corp_works_ajax_filter' );
add_action( 'wp_ajax_nopriv_corp_works_filter', 'corp_works_ajax_filter' );

ログインしていない人も使うので nopriv も付けます。
check_ajax_referer の第1引数は、wp_create_nonce( 'corp_works_filter' ) と同じ文字列です。

「さらに読み込む」は、同じフィルタ値を POST しつつ paged だけ進める、が続きです。この記事の本題はチップなので、load more は「同じ query_args を使う」とだけ覚えます。

AJAXを使う

URLにパラメータを付けると、普通のリンク遷移ではページが丸ごと読み直されます。そのとき起きるのが次の2つです。

スクロールが先頭に戻る(チップが画面の上の方にあるので、押すたびに一番上へ飛ばされる)
画面が一瞬白く(または空に)見える
AJAXにすると、一覧のブロックだけ差し替えます。アドレスバーのクエリは history.replaceState で更新するので、共有できるURLはそのまま、見た目のジャンプと白飛びだけ消えます。JSが無いときは、同じリンクで普通に再読み込みします。絞り込みの本体はURLで、AJAXは快適さのレイヤーです。

----
※チップはリンクなので、押すと本来はページが再読み込みされます。再読み込みすると、スクロールが先頭に戻り、画面が一瞬白く見えます。一覧のチップはページの途中にあるので、押すたびに一番上へ飛ばされると使いづらいです。

AJAXは、一覧だけ差し替えて、この再読み込みを避けるためです。URLのパラメータは残します。アドレスをコピーすれば同じ絞り込みを開けます。JSが無いときは、同じリンクで通常の再読み込みになります。

8. JavaScript

assets/js/works.js のフィルタ部分です。
リンクのデフォルト遷移を止めて AJAX し、失敗しても画面を白くしない。URL は replaceState(履歴を増やさない)。

(function () {
    'use strict';

    var config = window.corpWorks || {};
    var filterRoot = document.querySelector('.js-works-filter');
    var gridSection = document.querySelector('.js-works-grid-section');
    var isFiltering = false;

    function buildFilterUrl(service, building) {
        var url = config.archiveUrl || window.location.pathname;
        var params = [];
        if (service && service !== 'all') {
            params.push('works_service=' + encodeURIComponent(service));
        }
        if (building && building !== 'all') {
            params.push('works_building=' + encodeURIComponent(building));
        }
        return params.length ? (url + '?' + params.join('&')) : url;
    }

    function getActiveValue(axis) {
        var active = document.querySelector(
            '.js-works-filter-btn[data-filter-axis="' + axis + '"].is-active'
        );
        return active ? active.getAttribute('data-filter-value') : 'all';
    }

    function setActiveButton(axis, value) {
        document.querySelectorAll('.js-works-filter-btn[data-filter-axis="' + axis + '"]').forEach(function (btn) {
            btn.classList.toggle('is-active', btn.getAttribute('data-filter-value') === value);
        });
    }

    function applyFilter(service, building) {
        if (!config.ajaxUrl || !gridSection || isFiltering) {
            return;
        }
        isFiltering = true;
        gridSection.classList.add('is-loading');

        var form = new FormData();
        form.append('action', 'corp_works_filter');
        form.append('nonce', config.nonce || '');
        form.append('works_service', service || 'all');
        form.append('works_building', building || 'all');

        fetch(config.ajaxUrl, {
            method: 'POST',
            credentials: 'same-origin',
            body: form
        })
            .then(function (res) { return res.json(); })
            .then(function (json) {
                if (!json || !json.success || !json.data) {
                    throw new Error('filter failed');
                }
                setActiveButton('service', json.data.service || 'all');
                setActiveButton('building', json.data.building || 'all');
                gridSection.innerHTML = json.data.gridHtml || '';
                if (json.data.url && window.history && window.history.replaceState) {
                    window.history.replaceState({ worksFilter: true }, '', json.data.url);
                }
            })
            .catch(function () {
                // 失敗時は何もしない(リンク遷移に飛ばすとスクロールが飛ぶため)
            })
            .finally(function () {
                isFiltering = false;
                gridSection.classList.remove('is-loading');
            });
    }

    if (filterRoot) {
        filterRoot.addEventListener('click', function (e) {
            var btn = e.target.closest('.js-works-filter-btn');
            if (!btn) {
                return;
            }
            e.preventDefault();

            var axis = btn.getAttribute('data-filter-axis');
            var value = btn.getAttribute('data-filter-value') || 'all';
            var service = getActiveValue('service');
            var building = getActiveValue('building');

            if (axis === 'service') {
                service = value;
            } else if (axis === 'building') {
                building = value;
            }

            applyFilter(service, building);
        });
    }
})();

同じ軸の別チップを押すと、その軸だけ差し替わります。「電気」を選んだあと「オフィス」を選んでも、電気は残る。getActiveValue がそれをやっています。

同じ行(の絞り込み)を複数にすると、「件数が極端に減る」ほか、AND か OR かで逆の事故が起きます。

複雑にしないのが、使いやすくなるミソ。

一覧絞り込みは複数選択にしない

一覧のチップは、各行1つです。投稿にはタームをいくつ付けても構いません。矛盾に見えますが、役割が違います。

管理画面のチェックは、その実績は何かを書くためのものです。内装も空調もやった案件なら、両方付けます。付ける側は複数が自然です。

一覧のチップは、今どの窓から見るかです。行の中は1つ、行をまたぐときだけ同時に効きます。「内装工事」かつ「マンション」のように探す、という使い方です。

ここで同じ行も複数選択にすると、必ずこの分岐が出ます。

同じ行を AND にした場合
「内装工事」と「電気設備工事」の両方をONにすると、両方のタームが付いた実績だけ残ります。内装だけの案件、電気だけの案件は消えます。付けるときは「この仕事の一部」として気軽に複数チェックするのに、探すときは「全部持っている案件だけ」になる。件数が急に減って、壊れたように見えます。

同じ行を OR にした場合
「内装」または「電気」なので、どちらか付いていれば出ます。一覧はあまり減りません。「絞り込み」なのに、選ぶほど条件がゆるくなるので、今度は効いていないように感じます。

だから、付ける側は複数、同じ行の探し側は1つ、がわかりやすいです。行をまたぐ AND(事業 × 建物)だけ残します。これが「マンションの内装を見たい」に一番近いです。

カードに小さなタグがいくつも付き、今の条件と一致したものだけ色が付くのは、そのためです。上のチップが複数ONなのではなく、この案件はいくつも持っている。今はそのうちの窓から見ている、と見せています。

後から同じ行も複数にしたくなったら、そのときに「同じ行は OR、行をまたぐと AND」と先に決めてから足せます。データの持ち方は、すでに複数タームに対応しています。

9. CSS(チップの見た目)

.works-filter {
    margin-bottom: 48px;
    padding: 32px 0;
    border-top: 1px solid #e4e3e0;
    border-bottom: 1px solid #e4e3e0;
}

.works-filter__row + .works-filter__row {
    margin-top: 28px;
}

.works-filter__heading {
    font-size: 14px;
    margin-bottom: 16px;
}

.works-filter__list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    list-style: none;
    margin: 0;
    padding: 0;
}

.works-filter__btn {
    display: inline-flex;
    align-items: center;
    padding: 8px 14px;
    border: 1px solid #d2d1cd;
    font-size: 13px;
    color: #4a4a4a;
    background: #fff;
}

.works-filter__btn:hover,
.works-filter__btn.is-active {
    background: #1a1a1a;
    border-color: #1a1a1a;
    color: #fff;
}

.works-grid-section.is-loading {
    opacity: 0.55;
    pointer-events: none;
}

.works-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 32px 24px;
}

@media (max-width: 768px) {
    .works-grid {
        grid-template-columns: 1fr;
    }
}

初期表示は「すべて」に is-active が付きます。塗りつぶしは1つ、という見た目になります。

管理画面での付け方(再現手順)

コードを置いたあと、データを付けないとチップを押しても空です。

  1. 管理画面左「実績」が出ているか確認。無ければパーマリンク保存と、CPT UI との二重登録を疑う。
  2. 「実績 → 事業内容」「実績 → 建物の種類」で項目があるか確認。無ければ corp_works_ensure_terms が動いているか、init の優先度を確認。
  3. 「実績 → 新規追加」
    • タイトル
    • アイキャッチ(任意)
    • 右サイド(または下部)の 事業内容 にチェック
    • 建物の種類 にチェック
    • 公開
  4. /wp-admin/edit.php?post_type=works の一覧に、事業内容・建物の種類の列が出る(show_admin_column
  5. /works/ を開く。チップを押す。
    URL が ?works_service=... になればサーバー側は成功。
    画面がリロードせず一覧だけ変われば AJAX も成功。

投稿にタームが付いていないと、そのチップではヒットしません。これはバグではなくクエリどおりです。

登録をして、忘れずチェックも入れてね。という感じです。

動作確認チェックリスト

  • /works/ が 404 でない
  • チップに「すべて」以外が並ぶ
  • 「すべて」だけが最初から選択色
  • 事業を1つ選ぶと、建物の選択は維持される
  • 該当ゼロのとき「該当する実績がありません。」
  • その URL を別タブで開いても同じ結果(クエリが本体)
  • 管理画面一覧に2つの列がある
  • タームを管理画面で1個足すと、フロントのチップに増える(キャッシュに注意)

開発者ツールの Network で admin-ajax.php が action=corp_works_filter で 200、success: true なら JS 側はつながっています。-1 が返るときは nonce 名の不一致です。

つまずきやすいところ

「ソート」と頼まない。
ORDER BY の話と、出す集合の話が混ざります。

フロントだけ data-term で hide しない。
9件表示+追加読み込みがある時点で、サーバーが集合を知っている必要があります。

各軸は単一、軸のあいだは AND。
お問い合わせの複数選択と、見た目が似ていても別物です。

タームはコードではなく管理画面のマスター。
フィルタUIは get_terms()。投稿は wp_set_object_terms または編集画面のチェック。

AJAX と通常表示で条件関数を分けると、必ず1回ズレる。
corp_works_query_args() を両方から呼ぶ。

ここまでが、見た目のチップの裏にある一連です。

「誰が項目の正を持つか」WordPress ではタクソノミが、その役割に一番近いです。

【AI】イラストを描いてもらった

Flow」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

どこかスパイシーな気がします。