Búsqueda AJAX y filtros por taxonomía para un custom post type
Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura
Un directorio con cientos de entradas solo sirve si la gente puede acotarlo. Quien lo visita quiere escribir un nombre o elegir una categoría y ver los resultados al instante, sin recargar la página y sin perder su lugar.
Para eso construí CPT Ajax Filter & Search, para el directorio de proveedores de un cliente: una búsqueda AJAX que filtra un custom post type por palabra clave y taxonomía, con un campo de ACF para el enlace externo de cada proveedor. En este artículo verás cómo funciona el plugin, qué conviene reforzar si reutilizas el patrón en un proyecto nuevo y cómo extenderlo para filtrar por campos de ACF.
Cómo funciona el plugin
El plugin es un archivo PHP, un script y una hoja de estilos. Un shortcode como [cpt_ajax_filter post_type="vendor" taxonomy="vendor_category"] imprime un campo de búsqueda, un botón por cada término de la taxonomía y un primer grupo de resultados generado en el servidor.
- El script espera 400 ms después de la última tecla (un debounce), cancela cualquier petición que siga en curso y envía el texto buscado y el término elegido a
admin-ajax.phpjunto con un nonce. - El handler verifica el nonce con
check_ajax_referer(), sanitiza la entrada, ejecuta unWP_Querycontax_queryy el parámetro de búsquedas, y devuelve el HTML de las tarjetas conwp_send_json_success(). - Cada tarjeta lee un campo URL de ACF llamado
external_link. Si tiene valor, la tarjeta enlaza al sitio del proveedor en una pestaña nueva. Si no, enlaza a la entrada.
Esa base funciona bien. Si la reutilizas, cambia tres cosas: que el servidor decida qué se puede consultar, que los resultados se paginen y que quien usa lector de pantalla se entere cuando cambia la lista. Si además necesitas filtrar por campos de ACF, la misma estructura admite esa extensión con unas pocas líneas.
Deja las reglas de la consulta en el servidor
Un error común con este patrón es confiar en la petición: si el post type, la taxonomía y el tamaño de página viajan desde atributos data- hasta el handler y este los usa tal como llegan, cualquiera puede editar esos atributos en las herramientas de desarrollo. Así, un visitante podría pedir cualquier post type con entradas publicadas, incluso los que nunca pensaste listar, y posts_per_page=-1 permitiría traer todas las filas en una sola petición. Sanitizar un texto no lo convierte en un valor permitido.
La solución es enviar una clave corta y buscar la configuración real en el servidor. La configuración de abajo también incluye la extensión para ACF, que el plugin no trae: una lista de campos verdadero/falso por los que se puede filtrar. offers_discount es un campo hipotético que cambiarías por el tuyo, y puedes quitar todo lo relacionado con flags si te basta con palabra clave y taxonomía.
/**
* Configuraciones de filtro. El navegador solo envía la clave del array.
*/
function rw_filter_configs(): array {
return apply_filters( 'rw_filter_configs', [
'vendors' => [
'post_type' => 'vendor',
'taxonomy' => 'vendor_category',
'flags' => [ 'offers_discount' ], // Campos verdadero/falso de ACF.
'per_page' => 12,
],
] );
}
/**
* Arma los argumentos de WP_Query a partir de una configuración y de la entrada no confiable.
*/
function rw_filter_query_args( array $config, array $input ): array {
$args = [
'post_type' => $config['post_type'],
'post_status' => 'publish',
'posts_per_page' => $config['per_page'],
'paged' => max( 1, absint( $input['page'] ?? 1 ) ),
's' => sanitize_text_field( $input['search'] ?? '' ),
'orderby' => 'title',
'order' => 'ASC',
];
$terms = array_filter( (array) ( $input['terms'] ?? [] ), 'is_string' );
$terms = array_filter( array_map( 'sanitize_title', $terms ) );
if ( $terms ) {
$args['tax_query'] = [
[
'taxonomy' => $config['taxonomy'],
'field' => 'slug',
'terms' => $terms,
'operator' => 'IN', // Cualquier término elegido. Usa 'AND' para exigirlos todos.
],
];
}
$flags = (array) ( $input['flags'] ?? [] );
$meta = [];
foreach ( $config['flags'] as $field ) {
if ( ! empty( $flags[ $field ] ) ) {
$meta[] = [ 'key' => $field, 'value' => '1' ]; // ACF guarda verdadero/falso como "1" o "0".
}
}
if ( $meta ) {
$args['meta_query'] = array_merge( [ 'relation' => 'AND' ], $meta );
}
return $args;
}
ACF guarda los campos simples como post meta normal, con el nombre del campo como clave, y por eso funciona un meta_query común. Un campo verdadero/falso guarda "1" o "0", y un select guarda su valor como texto. Los campos checkbox y de selección múltiple guardan un array serializado, así que necesitan una comparación LIKE contra el valor entre comillas, que es más lenta y menos precisa. La referencia de WP_Query detalla la sintaxis completa de tax_query y meta_query.
El handler AJAX
El handler revisa el nonce, busca la configuración, ejecuta la consulta y devuelve tres valores: el HTML, el total de resultados y el número de páginas. El total alimenta el mensaje de estado, y el número de páginas le indica al script si debe mostrar el botón para cargar más.
add_action( 'wp_ajax_rw_filter', 'rw_filter_handler' );
add_action( 'wp_ajax_nopriv_rw_filter', 'rw_filter_handler' );
function rw_filter_handler(): void {
check_ajax_referer( 'rw_filter', 'nonce' );
$configs = rw_filter_configs();
$key = sanitize_key( wp_unslash( $_POST['config'] ?? '' ) );
if ( ! isset( $configs[ $key ] ) ) {
wp_send_json_error( [ 'message' => 'Unknown directory.' ], 400 );
}
$query = new WP_Query( rw_filter_query_args( $configs[ $key ], wp_unslash( $_POST ) ) );
wp_send_json_success( [
'html' => rw_filter_cards( $query ),
'found' => (int) $query->found_posts,
'pages' => (int) $query->max_num_pages,
] );
}
function rw_filter_cards( WP_Query $query ): string {
$html = '';
foreach ( $query->posts as $post ) {
// Misma regla que el plugin: la URL de ACF "external_link" tiene prioridad sobre el permalink.
$external = function_exists( 'get_field' ) ? (string) get_field( 'external_link', $post->ID ) : '';
$html .= sprintf(
'<li class="rw-card"><h3 class="rw-card__title"><a href="%1$s"%2$s>%3$s%4$s</a></h3><p>%5$s</p></li>',
esc_url( '' !== $external ? $external : get_permalink( $post ) ),
'' !== $external ? ' target="_blank" rel="noopener"' : '',
esc_html( get_the_title( $post ) ),
'' !== $external ? '<span class="screen-reader-text"> (opens in a new tab)</span>' : '',
esc_html( wp_trim_words( get_the_excerpt( $post ), 25 ) )
);
}
return $html;
}
Cada tarjeta es un elemento de lista con un único enlace real en su encabezado. El plugin hace clicable toda la tarjeta con un manejador de clic en JavaScript. Un «stretched link» en CSS (un pseudo-elemento ::after en el enlace que cubre la tarjeta) le da al mouse la misma área amplia, mientras que el teclado y los lectores de pantalla siguen encontrando un solo enlace normal. Si el enlace abre una pestaña nueva, dilo en el texto del enlace, como hace el span oculto.
Una nota sobre el nonce. Para visitantes sin sesión iniciada, un nonce de WordPress es igual para todos y sigue siendo válido entre 12 y 24 horas. Si una caché de página completa sirve la página por más tiempo, el nonce incrustado caduca y cada búsqueda falla con un 403. Mantén la duración de la caché de página por debajo de 12 horas o, como se trata de datos públicos de solo lectura, considera una ruta REST con validación estricta de argumentos. La documentación de nonces explica su vigencia.
Marcado accesible
El campo de búsqueda del plugin depende de un placeholder, y sus botones de filtro solo cambian una clase CSS. Unos pocos atributos resuelven ambas cosas:
- Un
<label>visible para el campo de búsqueda, ytype="search". - Botones de filtro que sean elementos
<button type="button">reales conaria-pressed, agrupados conrole="group"y un nombre accesible. - Un elemento con
role="status", que funciona como región viva de tipo polite, para anunciar el total, por ejemplo «12 vendors found». Anuncia el número y no la lista, para que el lector de pantalla no lea todas las tarjetas con cada tecla. aria-busyen la lista mientras hay una petición en curso.
En el marcado de abajo, la casilla es el filtro opcional de ACF de la configuración anterior.
add_action( 'init', function (): void {
wp_register_script( 'rw-filter', plugins_url( 'assets/filter.js', __FILE__ ), [], '1.0.0', [ 'strategy' => 'defer', 'in_footer' => true ] );
wp_add_inline_script( 'rw-filter', 'window.rwFilter = ' . wp_json_encode( [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'rw_filter' ),
] ) . ';', 'before' );
add_shortcode( 'rw_filter', 'rw_filter_shortcode' );
} );
function rw_filter_shortcode( $atts ): string {
$atts = shortcode_atts( [ 'config' => 'vendors' ], $atts, 'rw_filter' );
$key = sanitize_key( $atts['config'] );
$configs = rw_filter_configs();
if ( ! isset( $configs[ $key ] ) ) {
return '';
}
$terms = get_terms( [ 'taxonomy' => $configs[ $key ]['taxonomy'], 'hide_empty' => true ] );
$first = new WP_Query( rw_filter_query_args( $configs[ $key ], [] ) );
$id = wp_unique_id( 'rw-filter-' );
wp_enqueue_script( 'rw-filter' ); // Solo se carga en páginas que usan el shortcode.
ob_start();
?>
<div class="rw-filter" data-config="<?php echo esc_attr( $key ); ?>">
<form class="rw-filter__form" role="search">
<label for="<?php echo esc_attr( $id ); ?>"><?php esc_html_e( 'Search vendors', 'rw-filter' ); ?></label>
<input type="search" id="<?php echo esc_attr( $id ); ?>" name="search" autocomplete="off">
<div role="group" aria-label="<?php esc_attr_e( 'Filter by category', 'rw-filter' ); ?>">
<?php foreach ( is_wp_error( $terms ) ? [] : $terms as $term ) : ?>
<button type="button" aria-pressed="false" data-term="<?php echo esc_attr( $term->slug ); ?>"><?php echo esc_html( $term->name ); ?></button>
<?php endforeach; ?>
</div>
<label><input type="checkbox" name="flags[offers_discount]" value="1"> <?php esc_html_e( 'Offers a member discount', 'rw-filter' ); ?></label>
</form>
<p class="rw-filter__status" role="status" aria-live="polite"></p>
<ul class="rw-filter__results" aria-busy="false"><?php echo rw_filter_cards( $first ); // Se escapa adentro. ?></ul>
<button type="button" class="rw-filter__more"<?php echo $first->max_num_pages > 1 ? '' : ' hidden'; ?>><?php esc_html_e( 'Load more', 'rw-filter' ); ?></button>
</div>
<?php
return (string) ob_get_clean();
}
El shortcode encola su script desde el propio callback, así que el archivo solo se carga en las páginas que muestran el directorio. El script se registra antes con wp_register_script(), y el nonce y la URL de AJAX le llegan mediante wp_add_inline_script(). La primera página de resultados se genera en el servidor, de modo que el directorio funciona antes de que corra JavaScript y los buscadores pueden leer las entradas.
El script: debounce, cancelar, anunciar
El plugin usa jQuery. En código nuevo, fetch con un AbortController cumple la misma función que abortar la petición de jQuery. Todo queda acotado al elemento contenedor, así que dos directorios pueden convivir en la misma página. El plugin busca sus elementos por IDs fijos, lo que lo limita a una instancia por página.
document.querySelectorAll( '.rw-filter' ).forEach( ( root ) => {
const form = root.querySelector( 'form' );
const status = root.querySelector( '[role="status"]' );
const list = root.querySelector( '.rw-filter__results' );
const more = root.querySelector( '.rw-filter__more' );
let page = 1;
let timer;
let controller;
async function load( append = false ) {
controller?.abort(); // Cancela la petición anterior, como el xhr.abort() del plugin.
controller = new AbortController();
const { signal } = controller;
const body = new FormData( form );
body.append( 'action', 'rw_filter' );
body.append( 'nonce', window.rwFilter.nonce );
body.append( 'config', root.dataset.config );
body.append( 'page', String( page ) );
root.querySelectorAll( '[aria-pressed="true"]' ).forEach( ( btn ) => body.append( 'terms[]', btn.dataset.term ) );
list.setAttribute( 'aria-busy', 'true' );
try {
const res = await fetch( window.rwFilter.ajaxUrl, { method: 'POST', body, signal } );
const json = await res.json();
if ( ! res.ok || ! json.success ) {
throw new Error( 'Request failed' );
}
const firstNew = list.children.length;
if ( append ) {
list.insertAdjacentHTML( 'beforeend', json.data.html ); // Escapado en el servidor.
list.children[ firstNew ]?.querySelector( 'a' )?.focus();
} else {
list.innerHTML = json.data.html;
}
more.hidden = page >= json.data.pages;
status.textContent = json.data.found ? `${ json.data.found } vendors found` : 'No vendors match these filters.';
} catch ( err ) {
if ( err.name !== 'AbortError' ) {
page -= append ? 1 : 0; // Permite que "Load more" reintente la misma página.
status.textContent = 'Search failed. Please try again.';
}
} finally {
if ( ! signal.aborted ) list.setAttribute( 'aria-busy', 'false' );
}
}
const reset = () => { page = 1; load(); };
form.addEventListener( 'input', ( e ) => {
if ( e.target.type !== 'search' ) return;
clearTimeout( timer );
timer = setTimeout( reset, 400 ); // Debounce: espera una pausa al escribir.
} );
form.addEventListener( 'change', ( e ) => e.target.type === 'checkbox' && reset() );
form.addEventListener( 'submit', ( e ) => { e.preventDefault(); clearTimeout( timer ); reset(); } );
root.querySelectorAll( '[aria-pressed]' ).forEach( ( btn ) => btn.addEventListener( 'click', () => {
btn.setAttribute( 'aria-pressed', String( btn.getAttribute( 'aria-pressed' ) !== 'true' ) );
reset();
} ) );
more.addEventListener( 'click', () => { page++; load( true ); } );
} );
Hay varios detalles importantes. Cada cambio de filtro vuelve a la página 1. «Load more» agrega resultados y mueve el foco al primer enlace nuevo, para que quien navega con teclado siga donde empieza el contenido nuevo en lugar de volver arriba. Mientras alguien escribe, el foco se queda en el campo de búsqueda y solo cambia el mensaje de estado. El HTML que se inserta con innerHTML viene de tu propio endpoint y cada valor se escapó en el servidor; nunca insertes así HTML de una respuesta de terceros. La referencia del rol status en MDN explica cómo se anuncian las regiones vivas.
Límites de rendimiento que debes conocer
- Las meta queries no escalan como las taxonomías. Filtrar por valores de
wp_postmetava bien con unos cientos de entradas. Si un campo es un filtro principal sobre miles de entradas, conviértelo en taxonomía. - La búsqueda no cubre los campos de ACF. El parámetro
sbusca en el título, el extracto y el contenido, y desde WordPress 6.2 puedes acotarlo consearch_columns. WP_Query combinasymeta_querycon AND, así que «coincide con el título o con este campo» requiere un filtroposts_search. - El servidor fija el tamaño de página. Lo define la configuración, nunca el navegador.
Qué vigilar
- Comprueba que
get_terms()no haya devuelto unWP_Errorantes de recorrerlo, como hace el plugin. - Si conservas la animación escalonada del plugin para las tarjetas nuevas, desactívala con
prefers-reduced-motion: reduce. - Muestra un mensaje como «No vendors match these filters» en la región de estado, en lugar de dejar una cuadrícula vacía.
- Reinicia «Load more» cada vez que cambien los filtros, o se paginará sobre un conjunto de resultados equivocado.
- Prueba con un lector de pantalla: escribe un nombre, activa un filtro, carga más resultados y confirma que escuchas el total cada vez.
Este es el tipo de trabajo que hago para clientes: Plugins e integraciones