so no-JS submission falls back cleanly to a
* server-rendered filter; with JS, the IAPI store intercepts.
*
* @package airo-wp
* @since 2.1.0
*
* @param array $attributes Block attributes.
* @param string $content Serialized innerBlocks (unused — no inner blocks).
* @param WP_Block $block Block instance (provides context).
*/
defined( 'ABSPATH' ) || exit;
// Shared with the Query block (per-Query URL keys, chip and Reset links).
require_once dirname( __DIR__ ) . '/query/filter-links.php';
// ---------------------------------------------------------------------------
// Helper renderers (one per filterKind) — defined FIRST so the dispatcher
// below can call them on the first render (conditional function defs only
// become callable after the if-block executes).
// ---------------------------------------------------------------------------
if ( ! function_exists( 'airowp_query_filter_collect_term_scope' ) ) :
/**
* Collect the term IDs a query's taxQuery allows (or forbids) for one taxonomy.
*
* Walks the same nested clause/group shape the server builder understands
* (see airowp_build_tax_query_entry()), gathering leaf clauses that
* name this taxonomy. `IN` narrows the offer list; `NOT IN` removes from
* it. Clauses for other taxonomies are ignored — they constrain which
* posts match, not which terms of *this* taxonomy are worth offering.
*
* Deliberately shallow about boolean structure: an `OR` group could in
* principle make an `IN` clause non-binding, so the narrowing here is a
* best effort at "do not advertise what this query cannot return" rather
* than an exact solve. Erring toward a shorter list is the safer side —
* the alternative is what shipped, which advertised everything.
*
* @param array $entry A taxQuery, group, or leaf clause.
* @param string $taxonomy Taxonomy slug the filter renders.
* @param array $allowed Accumulator for allowed term IDs (by reference).
* @param array $blocked Accumulator for forbidden term IDs (by reference).
* @return void
*/
function airowp_query_filter_collect_term_scope( array $entry, $taxonomy, array &$allowed, array &$blocked ) {
if ( isset( $entry['clauses'] ) && is_array( $entry['clauses'] ) ) {
foreach ( $entry['clauses'] as $child ) {
if ( is_array( $child ) ) {
airowp_query_filter_collect_term_scope( $child, $taxonomy, $allowed, $blocked );
}
}
return;
}
if ( empty( $entry['taxonomy'] ) || empty( $entry['terms'] ) ) {
return;
}
if ( sanitize_key( (string) $entry['taxonomy'] ) !== $taxonomy ) {
return;
}
$terms = array_filter( array_map( 'absint', (array) $entry['terms'] ) );
$operator = isset( $entry['operator'] ) ? strtoupper( (string) $entry['operator'] ) : 'IN';
if ( 'NOT IN' === $operator ) {
$blocked = array_merge( $blocked, $terms );
return;
}
// IN and AND both mean "only these terms are in play".
$allowed = array_merge( $allowed, $terms );
}
endif;
if ( ! function_exists( 'airowp_query_filter_current_value' ) ) :
/**
* A filter's current value for its Query: the Query's own scoped key if
* present (even empty, which clears an inherited bare value), else the
* bare key. See airowp_query_owned_request_params().
*
* @param string $param_name Bare URL parameter name.
* @param string $query_id Sanitized queryId this filter belongs to.
* @return string|string[] Unslashed, unsanitized scalar or flat list of scalars; '' when absent.
*/
function airowp_query_filter_current_value( $param_name, $query_id ) {
$owned = airowp_query_owned_request_params( $query_id );
return isset( $owned[ $param_name ] ) ? airowp_query_scalar_param( wp_unslash( $owned[ $param_name ]['value'] ) ) : '';
}
endif;
if ( ! function_exists( 'airowp_query_filter_field' ) ) :
/**
* The name, id and carried-over hidden inputs for a filter's form field.
*
* The field writes the scoped key only when the page holds several
* Queries. The hidden inputs keep the rest of the URL (other Queries'
* filters, `page_id`) through a no-JS submission.
*
* @param string $param_name Bare URL parameter name.
* @param string $query_id Sanitized queryId this filter belongs to.
* @param bool $is_array Whether the field submits `name[]`.
* @return array{name:string,id:string,hidden:string}
*/
function airowp_query_filter_field( $param_name, $query_id, $is_array = false ) {
$scoped = $param_name . '__' . $query_id;
$multi = airowp_query_page_is_multi();
$name = $multi ? $scoped : $param_name;
// With several Queries the bare key stays for the others; with one,
// the field replaces it.
$hidden = airowp_query_preserved_inputs( $query_id, $multi ? array( $scoped ) : array( $param_name, $scoped ) );
// An unticked list must still override a bare value this Query inherited.
if ( $is_array && $multi ) {
$hidden .= sprintf( '', esc_attr( $scoped ) );
}
return array(
'name' => $name,
'id' => 'airo-wp-filter-' . sanitize_html_class( $scoped ),
'hidden' => $hidden,
);
}
endif;
if ( ! function_exists( 'airowp_query_filter_render_search' ) ) :
/**
* Render the search-input variation.
*
* @param string $wrapper Pre-computed get_block_wrapper_attributes() string.
* @param string $param_name URL parameter name (usually 'q').
* @param string $label Optional visible label.
* @param string $placeholder Input placeholder text.
* @param string $query_id Sanitized queryId this filter belongs to.
*/
function airowp_query_filter_render_search( $wrapper, $param_name, $label, $placeholder, $query_id = '' ) {
// $param_name is already sanitize_key()'d at the call site. Coerce array
// GET (?q[]=value) to a scalar so the input doesn't render "Array".
$raw = airowp_query_filter_current_value( $param_name, $query_id );
$raw = is_array( $raw ) ? ( isset( $raw[0] ) ? $raw[0] : '' ) : $raw;
$current = sanitize_text_field( (string) $raw );
$field = airowp_query_filter_field( $param_name, $query_id );
$input_id = $field['id'];
$aria_label = $label ? '' : ' aria-label="' . esc_attr__( 'Search', 'airo-wp' ) . '"';
printf(
'
',
$wrapper, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() output + appended data-wp-context JSON (sanitized values + wp_json_encode with JSON_HEX_APOS).
$label ? '' : '',
esc_attr( $field['name'] ),
esc_attr( $current ),
esc_attr( $placeholder ? $placeholder : __( 'Search…', 'airo-wp' ) ),
esc_html__( 'Search', 'airo-wp' ),
esc_attr( $input_id ),
$aria_label, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr__ used inside.
$field['hidden'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr() per input in airowp_query_preserved_inputs().
);
}
endif;
if ( ! function_exists( 'airowp_query_filter_render_sort' ) ) :
/**
* Render the sort-dropdown variation.
*
* @param string $wrapper Pre-computed wrapper attributes string.
* @param string $param_name URL parameter name (usually 'sort').
* @param string $label Optional visible label.
* @param array $options Array of {value, label} option definitions.
* @param string $query_id Sanitized queryId this filter belongs to.
*/
function airowp_query_filter_render_sort( $wrapper, $param_name, $label, array $options, $query_id = '' ) {
$raw = airowp_query_filter_current_value( $param_name, $query_id );
// Coerce array GET (?sort[]=value) to a scalar so `selected()` compares
// a string, not the literal "Array".
$raw = is_array( $raw ) ? ( isset( $raw[0] ) ? $raw[0] : '' ) : $raw;
$current = sanitize_text_field( (string) $raw );
$opts_html = '';
foreach ( $options as $opt ) {
$val = isset( $opt['value'] ) ? (string) $opt['value'] : '';
$opt_label = isset( $opt['label'] ) ? (string) $opt['label'] : $val;
$opts_html .= sprintf(
'',
esc_attr( $val ),
selected( $current, $val, false ),
esc_html( $opt_label )
);
}
$field = airowp_query_filter_field( $param_name, $query_id );
$select_id = $field['id'];
$aria_label = $label ? '' : ' aria-label="' . esc_attr__( 'Sort', 'airo-wp' ) . '"';
printf(
'',
$wrapper, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$label ? '' : '',
esc_attr( $field['name'] ),
esc_html__( 'Default order', 'airo-wp' ),
$opts_html, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each option is escaped per-attribute above.
esc_html__( 'Apply filter', 'airo-wp' ),
esc_attr( $select_id ),
$aria_label, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr__ used inside.
$field['hidden'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr() per input in airowp_query_preserved_inputs().
);
}
endif;
if ( ! function_exists( 'airowp_query_filter_render_select' ) ) :
/**
* Render the taxonomy single-select dropdown variation.
*
* @param string $wrapper Pre-computed wrapper attributes string.
* @param string $param_name URL parameter name.
* @param string $label Optional visible label.
* @param string $filter_taxonomy Taxonomy slug.
* @param bool $show_counts Whether to append (N) counts to option labels.
* @param array $active_filters Current active filter state for intersection counts.
* @param string $post_type Optional post-type scope for counts.
* @param array $term_include Term IDs the parent query allows, if any.
* @param array $term_exclude Term IDs the parent query excludes, if any.
* @param string $query_id Sanitized queryId this filter belongs to.
*/
function airowp_query_filter_render_select( $wrapper, $param_name, $label, $filter_taxonomy, $show_counts = false, $active_filters = array(), $post_type = '', $term_include = array(), $term_exclude = array(), $query_id = '' ) {
if ( ! taxonomy_exists( $filter_taxonomy ) ) {
return;
}
$term_args = array(
'taxonomy' => $filter_taxonomy,
'hide_empty' => false,
);
// Narrowed by the parent query's own taxQuery, so the filter cannot
// offer a term the query has already excluded.
if ( ! empty( $term_include ) ) {
$term_args['include'] = array_values( array_unique( $term_include ) );
}
if ( ! empty( $term_exclude ) ) {
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- a NOT IN clause on the parent query is exactly an exclusion; the list is the author's own small set, not user input.
$term_args['exclude'] = array_values( array_unique( $term_exclude ) );
}
$terms = get_terms( $term_args );
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return;
}
$raw = airowp_query_filter_current_value( $param_name, $query_id );
// Coerce array GET to scalar for the single-select variation.
$raw = is_array( $raw ) ? ( isset( $raw[0] ) ? $raw[0] : '' ) : $raw;
$current = sanitize_title( (string) $raw );
// Resolve counts if requested.
$counts = array();
if ( $show_counts && class_exists( '\airo-wp\Blocks\Query\FilterIndex' ) ) {
$term_ids = array_map(
function ( $t ) {
return (string) $t->term_id;
},
$terms
);
$counts = \airo-wp\Blocks\Query\FilterIndex::count_for_options(
$filter_taxonomy,
$term_ids,
$active_filters,
$post_type
);
}
$opts_html = '';
foreach ( $terms as $term ) {
// Browsers strip HTML from