商品とかの一覧ページを作って、絞り込み(フィルタ)させたいです。
その簡単キットを作るのが目標です。すぐ忘れるので。
普通のブログ記事以外で、商品一覧のようないろんなジャンルのものを表示させて、ボタンで絞り込み(フィルタ)したい。よくあるやつです。
今回の題材は「建築系コーポレートサイトの実績一覧ページ」です。
簡単に言いますと以下の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なら答えはだいたいこれです。
- カスタム投稿タイプ
works - タクソノミを2本(事業/建物)
- 管理画面で投稿に付ける
- 一覧は
tax_queryで取る - 見た目のチップはターム一覧から描く
一覧の絞り込み「オフィス」「電気設備工事」はボタンです。
実装では、管理画面で実績に付けた分類を、一覧の絞り込みに使っています。見た目の名前はチップ、データの名前はターム、分類の仕組みはタクソノミです。この記事では、管理画面のどこがそれに当たるかを先に示します。
専門用語が盛りだくさんです( ºωº; )ヌヌ…
こういう機能は以前に何回もやっていますが覚えていません (´•ω•`)
仕様を先に決める
記事を見てくれる人向けの、なんとなく理解用の表です。
| 項目 | 決め |
|---|---|
| 投稿タイプ | 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つ、という見た目になります。
管理画面での付け方(再現手順)
コードを置いたあと、データを付けないとチップを押しても空です。
- 管理画面左「実績」が出ているか確認。無ければパーマリンク保存と、CPT UI との二重登録を疑う。
- 「実績 → 事業内容」「実績 → 建物の種類」で項目があるか確認。無ければ
corp_works_ensure_termsが動いているか、initの優先度を確認。 - 「実績 → 新規追加」
- タイトル
- アイキャッチ(任意)
- 右サイド(または下部)の 事業内容 にチェック
- 建物の種類 にチェック
- 公開
/wp-admin/edit.php?post_type=worksの一覧に、事業内容・建物の種類の列が出る(show_admin_column)/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アカウントでログインして使えます。


星間旅路のメロディ
「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」
「この電波はどこの星からきたのだろうか。」
どこかスパイシーな気がします。



