diff --git a/inc/namespace.php b/inc/namespace.php
index 3a1c6a0..20f00e9 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -165,6 +165,62 @@ function pre_get_posts_transpose_query_vars( WP_Query $query ) : void {
}
}
+/**
+ * Resolve the terms a taxonomy filter block should offer.
+ *
+ * Two modes, selected by whether `includeTerms` is populated:
+ *
+ * - Curated: render exactly the listed terms, in the order given. Empty terms
+ * are kept, because naming a term explicitly is unambiguous intent.
+ * - Derived: render every term with posts, minus `excludeTerms`.
+ *
+ * Terms are addressed by slug rather than ID so that curated lists stay
+ * readable and reviewable in pattern markup.
+ *
+ * @param array $attributes Taxonomy filter block attributes.
+ * @return \WP_Term[] Terms to render, in display order.
+ */
+function get_filter_terms( array $attributes ) : array {
+ $include = array_filter( (array) ( $attributes['includeTerms'] ?? [] ) );
+ $exclude = array_filter( (array) ( $attributes['excludeTerms'] ?? [] ) );
+
+ // Non-ASCII slugs are stored URL-encoded but are authored raw. Query for both forms.
+ $include_slugs = ! empty( $include )
+ ? array_values( array_unique( array_merge( $include, array_map( 'rawurlencode', $include ) ) ) )
+ : '';
+
+ $terms = get_terms( [
+ 'taxonomy' => $attributes['taxonomy'],
+ 'hide_empty' => empty( $include ),
+ 'slug' => $include_slugs,
+ 'number' => 100,
+ ] );
+
+ if ( is_wp_error( $terms ) || empty( $terms ) ) {
+ return [];
+ }
+
+ if ( ! empty( $include ) ) {
+ // get_terms() ignores the order of a slug list, but a curated row is an
+ // editorial statement about prominence. Restore the authored order.
+ usort(
+ $terms,
+ fn ( $a, $b ) => array_search( urldecode( $a->slug ), $include, true ) <=> array_search( urldecode( $b->slug ), $include, true )
+ );
+
+ return $terms;
+ }
+
+ if ( ! empty( $exclude ) ) {
+ $terms = array_values( array_filter(
+ $terms,
+ fn ( $term ) => ! in_array( urldecode( $term->slug ), $exclude, true )
+ ) );
+ }
+
+ return $terms;
+}
+
/**
* Filters the settings determined from the block type metadata.
*
diff --git a/src/taxonomy/block.json b/src/taxonomy/block.json
index f0992b4..72987fb 100644
--- a/src/taxonomy/block.json
+++ b/src/taxonomy/block.json
@@ -62,6 +62,24 @@
"layoutDirection": {
"type": "string",
"default": "vertical"
+ },
+ "includeTerms": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": []
+ },
+ "excludeTerms": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": []
+ },
+ "maxVisibleTerms": {
+ "type": "number",
+ "default": 0
+ },
+ "showAllLabel": {
+ "type": "string",
+ "default": ""
}
},
"textdomain": "query-filter",
diff --git a/src/taxonomy/edit.js b/src/taxonomy/edit.js
index 4855e3d..6562353 100644
--- a/src/taxonomy/edit.js
+++ b/src/taxonomy/edit.js
@@ -1,6 +1,7 @@
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import {
+ FormTokenField,
PanelBody,
SelectControl,
TextControl,
@@ -18,6 +19,10 @@ export default function Edit( { attributes, setAttributes } ) {
showLabel,
displayType,
layoutDirection,
+ includeTerms,
+ excludeTerms,
+ maxVisibleTerms,
+ showAllLabel,
} = attributes;
const taxonomies = useSelect(
@@ -42,13 +47,38 @@ export default function Edit( { attributes, setAttributes } ) {
( select ) => {
return (
select( 'core' ).getEntityRecords( 'taxonomy', taxonomy, {
- number: 50,
+ per_page: 100,
} ) || []
);
},
[ taxonomy ]
);
+ // Term selection is stored as slugs, but presented to editors as names.
+ const termNames = terms.map( ( term ) => term.name );
+ const slugsToNames = ( slugs ) =>
+ slugs
+ .map(
+ ( slug ) => terms.find( ( term ) => term.slug === slug )?.name
+ )
+ .filter( Boolean );
+ const namesToSlugs = ( names ) =>
+ names
+ .map(
+ ( name ) => terms.find( ( term ) => term.name === name )?.slug
+ )
+ .filter( Boolean );
+
+ const isCurated = includeTerms.length > 0;
+
+ // The preview mirrors what the front end will render, so an editor can see
+ // the effect of curation and ordering without leaving the canvas.
+ const previewTerms = isCurated
+ ? includeTerms
+ .map( ( slug ) => terms.find( ( term ) => term.slug === slug ) )
+ .filter( Boolean )
+ : terms.filter( ( term ) => ! excludeTerms.includes( term.slug ) );
+
return (
<>
@@ -155,6 +185,75 @@ export default function Edit( { attributes, setAttributes } ) {
}
/>
+
+
+ setAttributes( {
+ includeTerms: namesToSlugs( names ),
+ } )
+ }
+ help={ __(
+ 'Leave empty to show every term with posts. When set, only these terms appear, in the order listed.',
+ 'query-filter'
+ ) }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+ { ! isCurated && (
+
+ setAttributes( {
+ excludeTerms: namesToSlugs( names ),
+ } )
+ }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+ ) }
+
+ setAttributes( {
+ maxVisibleTerms: parseInt( value, 10 ) || 0,
+ } )
+ }
+ help={ __(
+ 'Set to 0 to show all terms with no toggle.',
+ 'query-filter'
+ ) }
+ />
+ { maxVisibleTerms > 0 && (
+
+ setAttributes( { showAllLabel: value } )
+ }
+ />
+ ) }
+
{ showLabel && (
@@ -170,7 +269,7 @@ export default function Edit( { attributes, setAttributes } ) {
- { terms.map( ( term ) => (
+ { previewTerms.map( ( term ) => (
) ) }
@@ -192,7 +291,7 @@ export default function Edit( { attributes, setAttributes } ) {
/>
{ emptyLabel || __( 'All', 'query-filter' ) }
- { terms.map( ( term ) => (
+ { previewTerms.map( ( term ) => (