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( '
%9$s%2$s
', $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( '
%9$s%2$s
', $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