Elementor: AJAX item picker for tags & widgets (large datasets)

SkillAI & models

Let an Elementor control (in a Dynamic Tag or a widget)

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Elementor: AJAX item picker for tags & widgets (large datasets) skill

What this skill tells your AI

The instructions your AI receives, as published by lonsdale201/wp-agent-skills in elementor/elementor-dynamic-tag-ajax-select/SKILL.md and read by ahel’s review.

When a Dynamic Tag or widget setting must point at one specific record out of many — "this product", "that landing page", "this author" — you need a searchable picker. On a small set a preloaded SELECT2 is fine. On a 20k-product store it is a trap: Elementor renders every option into the panel on load and the editor hangs. This skill is the AJAX-search alternative and how to degrade it when Elementor Pro is absent.

The misconception (and why the editor freezes)

"I'll list the products in a SELECT2 so the user can search them."

// ANTI-PATTERN at scale — every product becomes a preloaded <option>
$options = [];
foreach ( wc_get_products( [ 'limit' => -1 ] ) as $p ) {
    $options[ $p->get_id() ] = $p->get_name();   // <-- 20k entries in the panel
}
$this->add_control( 'product_id', [
    'type'    => \Elementor\Controls_Manager::SELECT2,
    'options' => $options,
] );

A preloaded SELECT2 ships all options to the editor up front. That is exactly what the reference plugin's ProductAttributes tag does — but only because attribute taxonomies are a handful (ProductAttributes.php:62-74). The same shape over products/posts is what locks the panel. Preloaded SELECT2 is correct only for small, bounded option sets (a dozen statuses, a few taxonomies).

The fix — Elementor Pro's AJAX query control

The query control is a SELECT2 whose options are fetched on demand, by search term, over AJAX. Catalog size is irrelevant because nothing is queried until the user types.

use ElementorPro\Modules\QueryControl\Module as QueryControlModule;

$this->add_control( 'product_id', [
    'label'        => esc_html__( 'Product', 'myplugin' ),
    'type'         => QueryControlModule::QUERY_CONTROL_ID,   // 'query'
    'options'      => [],            // empty — filled by AJAX
    'label_block'  => true,
    'autocomplete' => [
        'object'  => QueryControlModule::QUERY_OBJECT_POST,   // what to search
        'query'   => [ 'post_type' => 'product' ],            // scope (search term is added server-side)
        'display' => 'minimal',                               // or 'detailed'
    ],
] );

Verified contract:

  • QUERY_CONTROL_ID = 'query'; the control class Query extends Control_Select2 (controls/query.php:12-16).
  • 'autocomplete'['object'] is one of (module.php:34-39): QUERY_OBJECT_POST ('post'), QUERY_OBJECT_TAX ('tax'), QUERY_OBJECT_AUTHOR ('author' — users who authored content), QUERY_OBJECT_USER ('user' — all users), QUERY_OBJECT_ATTACHMENT ('attachment'), QUERY_OBJECT_LIBRARY_TEMPLATE.
  • 'query' is merged into the WP query scope; 'display' is 'minimal' or 'detailed'; 'by_field' => 'ID' stores the chosen post ID.
  • The server-side AJAX handler is entirely Pro's — registered on elementor/ajax/register_actions (pro_panel_posts_control_filter_autocomplete, query_control_value_titles). Your code writes no wp_ajax_ handler; you only declare the autocomplete config and Pro does the search and the saved-value label resolution.

Why it doesn't freeze (verified)

The autocomplete handler returns early with a WP_Error when the search term is empty (module.php:211) — so nothing runs until the user types. When they do, autocomplete_query_for_post() sets $query['s'] = $data['q'] (module.php:242); the 'posts_per_page' => -1 alongside it (module.php:241) is harmless because the s search term bounds the result set. This is the inverse of the preloaded SELECT2: the query is small and on-demand, not large and upfront.

Using it inside a Dynamic Tag

The query control works in a Tag/Data_Tag exactly as in a widget — store the chosen ID in a setting and resolve it in render() / get_value(). Pro's Internal_URL data tag is the canonical example: a type selector plus per-type query controls (post_id, taxonomy_id, attachment_id, author_id), each with its own autocomplete['object'] and a condition (internal-url.php:72-142):

$this->add_control( 'post_id', [
    'label'        => esc_html__( 'Search & Select', 'myplugin' ),
    'type'         => QueryModule::QUERY_CONTROL_ID,
    'options'      => [],
    'label_block'  => true,
    'autocomplete' => [
        'object'  => QueryModule::QUERY_OBJECT_POST,
        'display' => 'detailed',
        'query'   => [ 'post_type' => 'any' ],
    ],
    'condition'    => [ 'type' => 'post' ],
] );

// …then resolve in get_value()/render():
$url = get_permalink( (int) $this->get_settings( 'post_id' ) );

Note: in the bundled reference plugin, no dynamic tag uses the AJAX query control — every SELECT2 tag (ProductAttributes, membership-plan tags) preloads a small, bounded set. The AJAX pattern there lives in the DynamicAddToCartWidget. For a tag, Pro's Internal_URL is the reference.

Graceful degradation when Pro is absent (required)

QueryControlModule only exists with Pro. Feature-detect and fall back to a manual ID field — the exact pattern in DynamicAddToCartWidget:

use Elementor\Controls_Manager;
use ElementorPro\Modules\QueryControl\Module as QueryControlModule;

private function has_query_control_support(): bool {
    return class_exists( QueryControlModule::class );   // Pro present?
}

private function product_control_type(): string {
    return $this->has_query_control_support()
        ? QueryControlModule::QUERY_CONTROL_ID
        : Controls_Manager::TEXT;                        // manual ID entry
}

// …building the control:
$control = [ 'label' => esc_html__( 'Product', 'myplugin' ), 'type' => $this->product_control_type() ];

if ( $this->has_query_control_support() ) {
    $control['autocomplete'] = [
        'object'   => QueryControlModule::QUERY_OBJECT_POST,
        'query'    => [ 'post_type' => 'product' ],
        'display'  => 'minimal',
        'by_field' => 'ID',
    ];
} else {
    $control['description'] = esc_html__( 'Enter the product ID manually. Activate Elementor Pro for the search picker.', 'myplugin' );
}
$this->add_control( 'product_id', $control );

Verified at DynamicAddToCartWidget.php:43-53,181-198. Either way the stored value is a post ID, so render()/get_value() resolves it identically regardless of which control produced it.

Critical rules

  • Never preload a SELECT2 from an unbounded query (products/posts/users). Preloaded options are for small, fixed sets only.
  • Use QUERY_CONTROL_ID + autocomplete for large datasets. It is search-scoped server-side; size doesn't matter.
  • The query control is Pro-only. Always class_exists( QueryControlModule::class ) and degrade to a Controls_Manager::TEXT manual-ID field (or a deliberately bounded SELECT2).
  • Write no AJAX handler. Pro owns pro_panel_posts_control_filter_autocomplete / query_control_value_titles; you only declare autocomplete.
  • 'options' => [] for a query control — options arrive via AJAX; preloading defeats the purpose.
  • Pick the right object: author = users who authored content, user = all users; post needs a query.post_type scope; tax searches terms.
  • The stored value is an ID — cast and resolve it (get_permalink( (int) $id ), wc_get_product( (int) $id )) and handle a missing/invalid ID.

Common mistakes

// WRONG — query control but options preloaded (pointless + slow)
$this->add_control( 'id', [
    'type'    => QueryControlModule::QUERY_CONTROL_ID,
    'options' => $all_products,           // <-- defeats the AJAX control
] );

// WRONG — hard-requiring Pro; control silently missing on free → no way to set a product
$this->add_control( 'id', [ 'type' => QueryControlModule::QUERY_CONTROL_ID, /* … */ ] );
// (no class_exists guard → fatal/empty when Pro inactive)

// WRONG — writing your own ajax handler for it
add_action( 'wp_ajax_my_product_search', /* … */ );   // <-- unnecessary; Pro handles query control AJAX

// RIGHT — feature-detect, degrade, let Pro do the AJAX
$type = class_exists( QueryControlModule::class )
    ? QueryControlModule::QUERY_CONTROL_ID
    : \Elementor\Controls_Manager::TEXT;

Cross-references

  • Run elementor-dynamic-tag-fields for the control types, Tag vs Data_Tag, and reading settings back.
  • Run elementor-dynamic-tag-register for registering the tag and the Pro-feature reality.
  • Run wc-product-search-select for the WooCommerce-native product search/select control (a non-Elementor alternative for product pickers).

What this skill does NOT cover

  • The query control's full option surface (custom query args per object type, include_type, sorting) — read module.php autocomplete_query_for_*.
  • Building a bespoke AJAX select without Pro — possible via a custom control + wp_ajax_, but out of scope; prefer the manual-ID degrade.
  • Query-control filtering for loop/posts widgets (the Group_Control query side) — this skill is about single-item pickers.
  • Caching/transients for the search results — Pro's handler runs uncached per keystroke; debounce/min-length is Pro's select2 default.

References

Signals

GitHub stars
22
Forks
2
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
elementor-dynamic-tag-ajax-select
Source
github.com/lonsdale201/wp-agent-skills