├── .gitignore ├── src ├── taxonomy │ ├── style-index.css │ ├── index.js │ ├── view.js │ ├── block.json │ ├── render.php │ └── edit.js └── post-type │ ├── index.js │ ├── block.json │ ├── render.php │ └── edit.js ├── composer.json ├── package.json ├── query-filter.php ├── CONTRIBUTING.md ├── README.md └── inc └── namespace.php /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /src/taxonomy/style-index.css: -------------------------------------------------------------------------------- 1 | @view-transition { 2 | navigation: auto; 3 | } 4 | 5 | .wp-block-query-filter { 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: stretch; 9 | } 10 | -------------------------------------------------------------------------------- /src/post-type/index.js: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import Edit from './edit'; 3 | import metadata from './block.json'; 4 | 5 | registerBlockType( metadata.name, { 6 | /** 7 | * @see ./edit.js 8 | */ 9 | edit: Edit, 10 | } ); 11 | -------------------------------------------------------------------------------- /src/taxonomy/index.js: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import Edit from './edit'; 3 | import metadata from './block.json'; 4 | import './style-index.css'; 5 | 6 | registerBlockType( metadata.name, { 7 | /** 8 | * @see ./edit.js 9 | */ 10 | edit: Edit, 11 | } ); 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/query-filter", 3 | "description": "Query Loop Block filters", 4 | "type": "wordpress-plugin", 5 | "require": { 6 | "composer/installers": "^1 || ^2" 7 | }, 8 | "license": "GPL-2.0-or-later", 9 | "authors": [ 10 | { 11 | "name": "Human Made Limited", 12 | "email": "hello@humanmade.com" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-loop-filter", 3 | "private": true, 4 | "description": "Filter blocks for query loops", 5 | "scripts": { 6 | "build": "wp-scripts build --experimental-modules", 7 | "start": "wp-scripts start --experimental-modules", 8 | "format": "wp-scripts format", 9 | "lint:css": "wp-scripts lint-style --fix", 10 | "lint:js": "wp-scripts lint-js", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "Human Made Limited", 14 | "license": "GPL-2.0-or-later", 15 | "dependencies": { 16 | "@wordpress/scripts": "^28.6.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /query-filter.php: -------------------------------------------------------------------------------- 1 | { 4 | const url = new URL( action ); 5 | if ( value || name === 's' ) { 6 | url.searchParams.set( name, value ); 7 | } else { 8 | url.searchParams.delete( name ); 9 | } 10 | const { actions } = await import( '@wordpress/interactivity-router' ); 11 | await actions.navigate( url.toString() ); 12 | }; 13 | 14 | const { state } = store( 'query-filter', { 15 | actions: { 16 | *navigate( e ) { 17 | e.preventDefault(); 18 | const { actions } = yield import( 19 | '@wordpress/interactivity-router' 20 | ); 21 | yield actions.navigate( e.target.value ); 22 | }, 23 | *search( e ) { 24 | e.preventDefault(); 25 | const { ref } = getElement(); 26 | let action, name, value; 27 | if ( ref.tagName === 'FORM' ) { 28 | const input = ref.querySelector( 'input[type="search"]' ); 29 | action = ref.action; 30 | name = input.name; 31 | value = input.value; 32 | } else { 33 | action = ref.closest( 'form' ).action; 34 | name = ref.name; 35 | value = ref.value; 36 | } 37 | 38 | // Don't navigate if the search didn't really change. 39 | if ( value === state.searchValue ) return; 40 | 41 | state.searchValue = value; 42 | 43 | yield updateURL( action, value, name ); 44 | }, 45 | }, 46 | } ); 47 | -------------------------------------------------------------------------------- /src/post-type/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 2, 4 | "name": "query-filter/post-type", 5 | "version": "0.1.0", 6 | "title": "Post Type Filter", 7 | "category": "theme", 8 | "icon": "filter", 9 | "description": "Allows users to filter by post type when placed wihin a query loop block", 10 | "ancestor": [ "core/query" ], 11 | "usesContext": [ "queryId", "query" ], 12 | "supports": { 13 | "html": false, 14 | "className": true, 15 | "customClassName": true, 16 | "color": { 17 | "background": true, 18 | "text": true 19 | }, 20 | "typography": { 21 | "fontSize": true, 22 | "textAlign": true, 23 | "lineHeight": true, 24 | "__experimentalFontFamily": true, 25 | "__experimentalFontWeight": true, 26 | "__experimentalFontStyle": true, 27 | "__experimentalTextTransform": true, 28 | "__experimentalTextDecoration": true, 29 | "__experimentalLetterSpacing": true, 30 | "__experimentalDefaultControls": { 31 | "fontSize": true 32 | } 33 | }, 34 | "spacing": { 35 | "margin": true, 36 | "padding": true, 37 | "blockGap": true 38 | }, 39 | "interactivity": { 40 | "clientNavigation": true 41 | } 42 | }, 43 | "attributes": { 44 | "emptyLabel": { 45 | "type": "string", 46 | "default": "" 47 | }, 48 | "label": { 49 | "type": "string" 50 | }, 51 | "showLabel": { 52 | "type": "boolean", 53 | "default": true 54 | } 55 | }, 56 | "textdomain": "query-filter", 57 | "editorScript": "file:./index.js", 58 | "viewScriptModule": "query-filter-taxonomy-view-script-module", 59 | "style": "query-filter-view", 60 | "render": "file:./render.php" 61 | } 62 | -------------------------------------------------------------------------------- /src/taxonomy/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "query-filter/taxonomy", 5 | "version": "0.1.0", 6 | "title": "Taxonomy Filter", 7 | "category": "theme", 8 | "icon": "filter", 9 | "description": "Allows users to filter by taxonomy terms when placed wihin a query loop block", 10 | "ancestor": [ "core/query" ], 11 | "usesContext": [ "queryId", "query" ], 12 | "supports": { 13 | "html": false, 14 | "className": true, 15 | "customClassName": true, 16 | "color": { 17 | "background": true, 18 | "text": true 19 | }, 20 | "typography": { 21 | "fontSize": true, 22 | "textAlign": true, 23 | "lineHeight": true, 24 | "__experimentalFontFamily": true, 25 | "__experimentalFontWeight": true, 26 | "__experimentalFontStyle": true, 27 | "__experimentalTextTransform": true, 28 | "__experimentalTextDecoration": true, 29 | "__experimentalLetterSpacing": true, 30 | "__experimentalDefaultControls": { 31 | "fontSize": true 32 | } 33 | }, 34 | "spacing": { 35 | "margin": true, 36 | "padding": true, 37 | "blockGap": true 38 | }, 39 | "interactivity": { 40 | "clientNavigation": true 41 | } 42 | }, 43 | "attributes": { 44 | "taxonomy": { 45 | "type": "string" 46 | }, 47 | "emptyLabel": { 48 | "type": "string", 49 | "default": "" 50 | }, 51 | "label": { 52 | "type": "string" 53 | }, 54 | "showLabel": { 55 | "type": "boolean", 56 | "default": true 57 | } 58 | }, 59 | "textdomain": "query-filter", 60 | "editorScript": "file:./index.js", 61 | "style": "query-filter-view", 62 | "viewScriptModule": "file:./view.js", 63 | "render": "file:./render.php" 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Query Loop Filters 2 | 3 | ![image](https://github.com/user-attachments/assets/85358de8-0929-47fe-85f5-b53a59fb522e) 4 | 5 | This plugin allows you to easily add filters to any query loop block. 6 | 7 | Provides 2 new blocks that can be added within a query loop block to allow filtering by either post type or a taxonomy. Also supports using the core search block to allow you to search. 8 | 9 | Compatible with both the core query loop block and the [Advanced query loop plugin](https://wordpress.org/plugins/advanced-query-loop/) (In fact, in order to use post type filters, use of the Advanced Query Loop plugin is required). 10 | 11 | Easy to use and lightweight, built using the WordPress Interactivity API. 12 | 13 | ## Usage 14 | 15 | * Add a query block. This can anyhere that the query block is supported e.g. page, template, or pattern. 16 | * Add one of the filter blocks and configure as required: 17 | * Taxonomy filter. Select which taxonomy to to use, customise the label (and whether it's shown), and customise the text used when none is selected. 18 | * Post type filter. Customise the label (and whether it's shown), as well as the text used when no filter is applied. 19 | * Search block. No extra options. 20 | 21 | ![image](https://github.com/user-attachments/assets/e2f9b62d-91f7-4c22-87ac-078b4d031a60) 22 | 23 | ## Installation 24 | 25 | ### Using Composer 26 | 27 | This plugin is available on packagist. 28 | 29 | `composer require humanmade/query-filter` 30 | 31 | ### Manually from Github. 32 | 33 | 1. Download the plugin from the [GitHub repository](https://github.com/humanmade/query-filter). 34 | 2. Upload the plugin to your site's `wp-content/plugins` directory. 35 | 3. Activate the plugin from the WordPress admin. 36 | -------------------------------------------------------------------------------- /src/taxonomy/render.php: -------------------------------------------------------------------------------- 1 | context['query']['inherit'] ) ) { 11 | $query_id = $block->context['queryId'] ?? 0; 12 | $query_var = sprintf( 'query-%d-%s', $query_id, $attributes['taxonomy'] ); 13 | $page_var = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; 14 | $base_url = remove_query_arg( [ $query_var, $page_var ] ); 15 | } else { 16 | $query_var = sprintf( 'query-%s', $attributes['taxonomy'] ); 17 | $page_var = 'page'; 18 | $base_url = str_replace( '/page/' . get_query_var( 'paged' ), '', remove_query_arg( [ $query_var, $page_var ] ) ); 19 | } 20 | 21 | $terms = get_terms( [ 22 | 'hide_empty' => true, 23 | 'taxonomy' => $attributes['taxonomy'], 24 | 'number' => 100, 25 | ] ); 26 | 27 | if ( is_wp_error( $terms ) || empty( $terms ) ) { 28 | return; 29 | } 30 | ?> 31 | 32 |
'wp-block-query-filter' ] ); ?> data-wp-interactive="query-filter" data-wp-context="{}"> 33 | 36 | 42 |
43 | -------------------------------------------------------------------------------- /src/post-type/render.php: -------------------------------------------------------------------------------- 1 | context['query']['inherit'] ) { 7 | $query_var = 'query-post_type'; 8 | $page_var = 'page'; 9 | $base_url = str_replace( '/page/' . get_query_var( 'paged' ), '', remove_query_arg( [ $query_var, $page_var ] ) ); 10 | } else { 11 | $query_id = $block->context['queryId'] ?? 0; 12 | $query_var = sprintf( 'query-%d-post_type', $query_id ); 13 | $page_var = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; 14 | $base_url = remove_query_arg( [ $query_var, $page_var ] ); 15 | } 16 | 17 | $post_types = array_map( 'trim', explode( ',', $block->context['query']['postType'] ?? 'post' ) ); 18 | 19 | // Support for enhanced query block. 20 | if ( isset( $block->context['query']['multiple_posts'] ) && is_array( $block->context['query']['multiple_posts'] ) ) { 21 | $post_types = array_merge( $post_types, $block->context['query']['multiple_posts'] ); 22 | } 23 | 24 | // Fill in inherited query types. 25 | if ( $block->context['query']['inherit'] ) { 26 | $inherited_post_types = $wp_query->get( 'query-filter-post_type' ) === 'any' 27 | ? get_post_types( [ 'public' => true, 'exclude_from_search' => false ] ) 28 | : (array) $wp_query->get( 'query-filter-post_type' ); 29 | 30 | $post_types = array_merge( $post_types, $inherited_post_types ); 31 | if ( ! get_option( 'wp_attachment_pages_enabled' ) ) { 32 | $post_types = array_diff( $post_types, [ 'attachment' ] ); 33 | } 34 | } 35 | 36 | $post_types = array_unique( $post_types ); 37 | $post_types = array_map( 'get_post_type_object', $post_types ); 38 | 39 | if ( empty( $post_types ) ) { 40 | return; 41 | } 42 | ?> 43 | 44 |
'wp-block-query-filter' ] ); ?> data-wp-interactive="query-filter" data-wp-context="{}"> 45 | 48 | 54 |
55 | -------------------------------------------------------------------------------- /src/post-type/edit.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { useBlockProps, InspectorControls } from '@wordpress/block-editor'; 3 | import { PanelBody, TextControl, ToggleControl } from '@wordpress/components'; 4 | import { useSelect } from '@wordpress/data'; 5 | 6 | export default function Edit( { attributes, setAttributes, context } ) { 7 | const { emptyLabel, label, showLabel } = attributes; 8 | 9 | const allPostTypes = useSelect( ( select ) => { 10 | return ( 11 | ( select( 'core' ).getPostTypes( { per_page: 100 } ) || [] ).filter( 12 | ( type ) => type.viewable 13 | ) || [] 14 | ); 15 | }, [] ); 16 | 17 | let contextPostTypes = ( context.query.postType || '' ) 18 | .split( ',' ) 19 | .map( ( type ) => type.trim() ); 20 | 21 | // Support for enhanced query loop block plugin. 22 | if ( Array.isArray( context.query.multiple_posts ) ) { 23 | contextPostTypes = contextPostTypes.concat( 24 | context.query.multiple_posts 25 | ); 26 | } 27 | 28 | const postTypes = contextPostTypes.map( ( postType ) => { 29 | return ( 30 | allPostTypes.find( ( type ) => type.slug === postType ) || { 31 | slug: postType, 32 | name: postType, 33 | } 34 | ); 35 | } ); 36 | 37 | return ( 38 | <> 39 | 40 | 41 | setAttributes( { label } ) } 50 | /> 51 | 55 | setAttributes( { showLabel } ) 56 | } 57 | /> 58 | 63 | setAttributes( { emptyLabel } ) 64 | } 65 | /> 66 | 67 | 68 |
69 | { showLabel && ( 70 | 73 | ) } 74 | 85 |
86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/taxonomy/edit.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { useBlockProps, InspectorControls } from '@wordpress/block-editor'; 3 | import { 4 | PanelBody, 5 | SelectControl, 6 | TextControl, 7 | ToggleControl, 8 | } from '@wordpress/components'; 9 | import { useSelect } from '@wordpress/data'; 10 | 11 | export default function Edit( { attributes, setAttributes } ) { 12 | const { taxonomy, emptyLabel, label, showLabel } = attributes; 13 | 14 | const taxonomies = useSelect( 15 | ( select ) => { 16 | const results = ( 17 | select( 'core' ).getTaxonomies( { per_page: 100 } ) || [] 18 | ).filter( ( taxonomy ) => taxonomy.visibility.publicly_queryable ); 19 | 20 | if ( results && results.length > 0 && ! taxonomy ) { 21 | setAttributes( { 22 | taxonomy: results[ 0 ].slug, 23 | label: results[ 0 ].name, 24 | } ); 25 | } 26 | 27 | return results; 28 | }, 29 | [ taxonomy ] 30 | ); 31 | 32 | const terms = useSelect( 33 | ( select ) => { 34 | return ( 35 | select( 'core' ).getEntityRecords( 'taxonomy', taxonomy, { 36 | number: 50, 37 | } ) || [] 38 | ); 39 | }, 40 | [ taxonomy ] 41 | ); 42 | 43 | return ( 44 | <> 45 | 46 | 47 | ( { 51 | label: taxonomy.name, 52 | value: taxonomy.slug, 53 | } ) ) } 54 | onChange={ ( taxonomy ) => 55 | setAttributes( { 56 | taxonomy, 57 | label: taxonomies.find( 58 | ( tax ) => tax.slug === taxonomy 59 | ).name, 60 | } ) 61 | } 62 | /> 63 | setAttributes( { label } ) } 71 | /> 72 | 76 | setAttributes( { showLabel } ) 77 | } 78 | /> 79 | 84 | setAttributes( { emptyLabel } ) 85 | } 86 | /> 87 | 88 | 89 |
90 | { showLabel && ( 91 | 94 | ) } 95 | 106 |
107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /inc/namespace.php: -------------------------------------------------------------------------------- 1 | WP_Query as parsed by the block context. 61 | * @param \WP_Block $block Block instance. 62 | * @param int $page Current query's page. 63 | * @return array Array containing parameters for WP_Query as parsed by the block context. 64 | */ 65 | function filter_query_loop_block_query_vars( array $query, \WP_Block $block, int $page ) : array { 66 | if ( isset( $block->context['queryId'] ) ) { 67 | $query['query_id'] = $block->context['queryId']; 68 | } 69 | 70 | return $query; 71 | } 72 | 73 | /** 74 | * Fires after the query variable object is created, but before the actual query is run. 75 | * 76 | * @param WP_Query $query The WP_Query instance (passed by reference). 77 | */ 78 | function pre_get_posts_transpose_query_vars( WP_Query $query ) : void { 79 | $query_id = $query->get( 'query_id', null ); 80 | 81 | if ( ! $query->is_main_query() && is_null( $query_id ) ) { 82 | return; 83 | } 84 | 85 | $prefix = $query->is_main_query() ? 'query-' : "query-{$query_id}-"; 86 | $tax_query = []; 87 | $valid_keys = [ 88 | 'post_type' => $query->is_search() ? 'any' : 'post', 89 | 's' => '', 90 | ]; 91 | 92 | // Preserve valid params for later retrieval. 93 | foreach ( $valid_keys as $key => $default ) { 94 | $query->set( 95 | "query-filter-$key", 96 | $query->get( $key, $default ) 97 | ); 98 | } 99 | 100 | // Map get params to this query. 101 | foreach ( $_GET as $key => $value ) { 102 | if ( strpos( $key, $prefix ) === 0 ) { 103 | $key = str_replace( $prefix, '', $key ); 104 | $value = sanitize_text_field( urldecode( wp_unslash( $value ) ) ); 105 | 106 | // Handle taxonomies specifically. 107 | if ( get_taxonomy( $key ) ) { 108 | $tax_query['relation'] = 'AND'; 109 | $tax_query[] = [ 110 | 'taxonomy' => $key, 111 | 'terms' => [ $value ], 112 | 'field' => 'slug', 113 | ]; 114 | } else { 115 | // Other options should map directly to query vars. 116 | $key = sanitize_key( $key ); 117 | 118 | if ( ! in_array( $key, array_keys( $valid_keys ), true ) ) { 119 | continue; 120 | } 121 | 122 | $query->set( 123 | $key, 124 | $value 125 | ); 126 | } 127 | } 128 | } 129 | 130 | if ( ! empty( $tax_query ) ) { 131 | $existing_query = $query->get( 'tax_query', [] ); 132 | 133 | if ( ! empty( $existing_query ) ) { 134 | $tax_query = [ 135 | 'relation' => 'AND', 136 | [ $existing_query ], 137 | $tax_query, 138 | ]; 139 | } 140 | 141 | $query->set( 'tax_query', $tax_query ); 142 | } 143 | } 144 | 145 | /** 146 | * Filters the settings determined from the block type metadata. 147 | * 148 | * @param array $metadata Metadata provided for registering a block type. 149 | * @return array Array of metadata for registering a block type. 150 | */ 151 | function filter_block_type_metadata( array $metadata ) : array { 152 | // Add query context to search block. 153 | if ( $metadata['name'] === 'core/search' ) { 154 | $metadata['usesContext'] = array_merge( $metadata['usesContext'] ?? [], [ 'queryId', 'query' ] ); 155 | } 156 | 157 | return $metadata; 158 | } 159 | 160 | /** 161 | * Filters the content of a single block. 162 | * 163 | * @param string $block_content The block content. 164 | * @param array $block The full block, including name and attributes. 165 | * @param \WP_Block $instance The block instance. 166 | * @return string The block content. 167 | */ 168 | function render_block_search( string $block_content, array $block, \WP_Block $instance ) : string { 169 | if ( empty( $instance->context['query'] ) ) { 170 | return $block_content; 171 | } 172 | 173 | wp_enqueue_script_module( 'query-filter-taxonomy-view-script-module' ); 174 | 175 | $query_var = empty( $instance->context['query']['inherit'] ) 176 | ? sprintf( 'query-%d-s', $instance->context['queryId'] ?? 0 ) 177 | : 's'; 178 | 179 | $action = str_replace( '/page/'. get_query_var( 'paged', 1 ), '', add_query_arg( [ $query_var => '' ] ) ); 180 | 181 | // Note sanitize_text_field trims whitespace from start/end of string causing unexpected behaviour. 182 | $value = wp_unslash( $_GET[ $query_var ] ?? '' ); 183 | $value = urldecode( $value ); 184 | $value = wp_check_invalid_utf8( $value ); 185 | $value = wp_pre_kses_less_than( $value ); 186 | $value = strip_tags( $value ); 187 | 188 | wp_interactivity_state( 'query-filter', [ 189 | 'searchValue' => $value, 190 | ] ); 191 | 192 | $block_content = new WP_HTML_Tag_Processor( $block_content ); 193 | $block_content->next_tag( [ 'tag_name' => 'form' ] ); 194 | $block_content->set_attribute( 'action', $action ); 195 | $block_content->set_attribute( 'data-wp-interactive', 'query-filter' ); 196 | $block_content->set_attribute( 'data-wp-on--submit', 'actions.search' ); 197 | $block_content->set_attribute( 'data-wp-context', '{searchValue:""}' ); 198 | $block_content->next_tag( [ 'tag_name' => 'input', 'class_name' => 'wp-block-search__input' ] ); 199 | $block_content->set_attribute( 'name', $query_var ); 200 | $block_content->set_attribute( 'inputmode', 'search' ); 201 | $block_content->set_attribute( 'value', $value ); 202 | $block_content->set_attribute( 'data-wp-bind--value', 'state.searchValue' ); 203 | $block_content->set_attribute( 'data-wp-on--input', 'actions.search' ); 204 | 205 | return (string) $block_content; 206 | } 207 | 208 | /** 209 | * Add data attributes to the query block to describe the block query. 210 | * 211 | * @param string $block_content Default query content. 212 | * @param array $block Parsed block. 213 | * @return string 214 | */ 215 | function render_block_query( $block_content, $block ) { 216 | $block_content = new WP_HTML_Tag_Processor( $block_content ); 217 | $block_content->next_tag(); 218 | 219 | // Always allow region updates on interactivity, use standard core region naming. 220 | $block_content->set_attribute( 'data-wp-interactive', 'query-filter' ); 221 | $block_content->set_attribute( 'data-wp-router-region', 'query-' . ( $block['attrs']['queryId'] ?? 0 ) ); 222 | 223 | return (string) $block_content; 224 | } 225 | --------------------------------------------------------------------------------