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 ) => (