Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
18 changes: 18 additions & 0 deletions src/taxonomy/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 103 additions & 4 deletions src/taxonomy/edit.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import {
FormTokenField,
PanelBody,
SelectControl,
TextControl,
Expand All @@ -18,6 +19,10 @@ export default function Edit( { attributes, setAttributes } ) {
showLabel,
displayType,
layoutDirection,
includeTerms,
excludeTerms,
maxVisibleTerms,
showAllLabel,
} = attributes;

const taxonomies = useSelect(
Expand All @@ -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 (
<>
<InspectorControls>
Expand Down Expand Up @@ -155,6 +185,75 @@ export default function Edit( { attributes, setAttributes } ) {
}
/>
</PanelBody>
<PanelBody
title={ __( 'Terms', 'query-filter' ) }
initialOpen={ false }
>
<FormTokenField
label={ __(
'Include only these terms',
'query-filter'
) }
value={ slugsToNames( includeTerms ) }
suggestions={ termNames }
onChange={ ( names ) =>
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 && (
<FormTokenField
label={ __(
'Exclude these terms',
'query-filter'
) }
value={ slugsToNames( excludeTerms ) }
suggestions={ termNames }
onChange={ ( names ) =>
setAttributes( {
excludeTerms: namesToSlugs( names ),
} )
}
__nextHasNoMarginBottom
__next40pxDefaultSize
/>
) }
<TextControl
label={ __(
'Terms shown before "show all"',
'query-filter'
) }
type="number"
min={ 0 }
value={ maxVisibleTerms }
onChange={ ( value ) =>
setAttributes( {
maxVisibleTerms: parseInt( value, 10 ) || 0,
} )
}
help={ __(
'Set to 0 to show all terms with no toggle.',
'query-filter'
) }
/>
{ maxVisibleTerms > 0 && (
<TextControl
label={ __( 'Show all label', 'query-filter' ) }
value={ showAllLabel }
placeholder={ __( 'See all', 'query-filter' ) }
onChange={ ( value ) =>
setAttributes( { showAllLabel: value } )
}
/>
) }
</PanelBody>
</InspectorControls>
<div { ...useBlockProps( { className: 'wp-block-query-filter' } ) }>
{ showLabel && (
Expand All @@ -170,7 +269,7 @@ export default function Edit( { attributes, setAttributes } ) {
<option>
{ emptyLabel || __( 'All', 'query-filter' ) }
</option>
{ terms.map( ( term ) => (
{ previewTerms.map( ( term ) => (
<option key={ term.slug }>{ term.name }</option>
) ) }
</select>
Expand All @@ -192,7 +291,7 @@ export default function Edit( { attributes, setAttributes } ) {
/>
{ emptyLabel || __( 'All', 'query-filter' ) }
</label>
{ terms.map( ( term ) => (
{ previewTerms.map( ( term ) => (
<label key={ term.slug }>
<input
type="radio"
Expand All @@ -212,7 +311,7 @@ export default function Edit( { attributes, setAttributes } ) {
: ''
}` }
>
{ terms.map( ( term ) => (
{ previewTerms.map( ( term ) => (
<label key={ term.slug }>
<input type="checkbox" inert />
{ term.name }
Expand Down
82 changes: 58 additions & 24 deletions src/taxonomy/render.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,9 @@
$base_url = str_replace( '/page/' . get_query_var( 'paged' ), '', remove_query_arg( [ $query_var, $page_var ] ) );
}

$terms = get_terms( [
'hide_empty' => true,
'taxonomy' => $attributes['taxonomy'],
'number' => 100,
] );
$terms = \HM\Query_Loop_Filter\get_filter_terms( $attributes );

if ( is_wp_error( $terms ) || empty( $terms ) ) {
if ( empty( $terms ) ) {
return;
}

Expand All @@ -40,9 +36,56 @@
// pre_get_posts_transpose_query_vars() to compare directly against urldecode($term->slug).
// phpcs:ignore HM.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff can't perceive the sanitize_text_field() outside the urldecode().
$current_value = sanitize_text_field( urldecode( wp_unslash( $_GET[ $query_var ] ?? '' ) ) );

$selected_terms = wp_parse_list( $current_value );

// Terms past the cap are collapsed behind a "show all" toggle. A term the visitor has
// already selected is always visible, so the control never hides its own active state.
$max_visible = (int) ( $attributes['maxVisibleTerms'] ?? 0 );
$has_overflow = $max_visible > 0 && count( $terms ) > $max_visible;
$show_all_label = $attributes['showAllLabel'] ?: __( 'See all', 'query-filter' );

/**
* Return whether a term should be hidden behind the "show all" toggle.
*
* @param int $index Position of the term in the rendered list.
* @param WP_Term $term Term being rendered.
* @return bool True when the term belongs to the collapsed overflow.
*/
$is_overflow_term = function ( int $index, WP_Term $term ) use ( $has_overflow, $max_visible, $selected_terms ) : bool {
if ( ! $has_overflow || $index < $max_visible ) {
return false;
}

return ! in_array( urldecode( $term->slug ), $selected_terms, true );
};

/**
* Build the URL that toggles a term on or off within the current selection.
*
* @param WP_Term $term Term to toggle.
* @return string URL representing the selection with this term flipped.
*/
$toggle_url = function ( WP_Term $term ) use ( $selected_terms, $query_var, $page_var, $base_url ) : string {
$slug = urldecode( $term->slug );
$next = in_array( $slug, $selected_terms, true )
? array_diff( $selected_terms, [ $slug ] )
: array_merge( $selected_terms, [ $slug ] );
$next = array_filter( $next );

return empty( $next )
? $base_url
: add_query_arg( [ $query_var => implode( ',', $next ), $page_var => false ], $base_url );
};

$context = [];

if ( $has_overflow ) {
$context['showAllTerms'] = false;
}
?>

<div <?php echo get_block_wrapper_attributes( [ 'class' => 'wp-block-query-filter' ] ); ?> data-wp-interactive="query-filter" data-wp-context="{}">
<div <?php echo get_block_wrapper_attributes( [ 'class' => 'wp-block-query-filter' ] ); ?> data-wp-interactive="query-filter" data-wp-context="<?php echo esc_attr( wp_json_encode( (object) $context ) ); ?>">
<label class="wp-block-query-filter-taxonomy__label wp-block-query-filter__label<?php echo $attributes['showLabel'] ? '' : ' screen-reader-text'; ?>" for="<?php echo esc_attr( $id ); ?>">
<?php echo esc_html( $attributes['label'] ?? $taxonomy->label ); ?>
</label>
Expand Down Expand Up @@ -73,26 +116,17 @@
</div>
<?php elseif ( $display_type === 'checkbox' ) : ?>
<div class="wp-block-query-filter-taxonomy__checkbox-group wp-block-query-filter__checkbox-group<?php echo $layout_direction === 'horizontal' ? ' horizontal' : ''; ?>">
<?php
$selected_terms = wp_parse_list( $current_value );
?>
<?php foreach ( $terms as $term ) : ?>
<?php
$slug = urldecode( $term->slug );
$is_checked = in_array( $slug, $selected_terms, true );
$new_terms = $is_checked
? array_diff( $selected_terms, [ $slug ] )
: array_merge( $selected_terms, [ $slug ] );
$new_terms = array_filter( $new_terms );
$checkbox_url = empty( $new_terms )
? $base_url
: add_query_arg( [ $query_var => implode( ',', $new_terms ), $page_var => false ], $base_url );
?>
<label>
<input type="checkbox" value="<?php echo esc_attr( $checkbox_url ); ?>" data-wp-on--change="actions.navigate" <?php checked( $is_checked ); ?> />
<?php foreach ( $terms as $index => $term ) : ?>
<label<?php echo $is_overflow_term( $index, $term ) ? ' class="is-overflow-term" data-wp-bind--hidden="!context.showAllTerms"' : ''; ?>>
<input type="checkbox" value="<?php echo esc_attr( $toggle_url( $term ) ); ?>" data-wp-on--change="actions.navigate" <?php checked( in_array( urldecode( $term->slug ), $selected_terms, true ) ); ?> />
<?php echo esc_html( $term->name ); ?>
</label>
<?php endforeach; ?>
<?php if ( $has_overflow ) : ?>
<button type="button" class="wp-block-query-filter__show-all" data-wp-on--click="actions.toggleAllTerms" data-wp-bind--hidden="context.showAllTerms">
<?php echo esc_html( $show_all_label ); ?>
</button>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
4 changes: 4 additions & 0 deletions src/taxonomy/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ store( 'query-filter', {
);
yield actions.navigate( e.target.value );
},
toggleAllTerms() {
const context = getContext();
context.showAllTerms = ! context.showAllTerms;
},
*search( e ) {
e.preventDefault();
// Scope search to block context so multiple searchable query loops may coexist.
Expand Down