$npx -y skills add Lonsdale201/wp-agent-skills --skill elementor-dynamic-tag-ajax-selectLet an Elementor control (in a Dynamic Tag or a widget)
| 1 | # Elementor: AJAX item picker for tags & widgets (large datasets) |
| 2 | |
| 3 | 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. |
| 4 | |
| 5 | ## The misconception (and why the editor freezes) |
| 6 | |
| 7 | > "I'll list the products in a `SELECT2` so the user can search them." |
| 8 | |
| 9 | ```php |
| 10 | // ANTI-PATTERN at scale — every product becomes a preloaded <option> |
| 11 | $options = []; |
| 12 | foreach ( wc_get_products( [ 'limit' => -1 ] ) as $p ) { |
| 13 | $options[ $p->get_id() ] = $p->get_name(); // <-- 20k entries in the panel |
| 14 | } |
| 15 | $this->add_control( 'product_id', [ |
| 16 | 'type' => \Elementor\Controls_Manager::SELECT2, |
| 17 | 'options' => $options, |
| 18 | ] ); |
| 19 | ``` |
| 20 | |
| 21 | 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](ProductAttributes.php)). 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). |
| 22 | |
| 23 | ## The fix — Elementor Pro's AJAX query control |
| 24 | |
| 25 | 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. |
| 26 | |
| 27 | ```php |
| 28 | use ElementorPro\Modules\QueryControl\Module as QueryControlModule; |
| 29 | |
| 30 | $this->add_control( 'product_id', [ |
| 31 | 'label' => esc_html__( 'Product', 'myplugin' ), |
| 32 | 'type' => QueryControlModule::QUERY_CONTROL_ID, // 'query' |
| 33 | 'options' => [], // empty — filled by AJAX |
| 34 | 'label_block' => true, |
| 35 | 'autocomplete' => [ |
| 36 | 'object' => QueryControlModule::QUERY_OBJECT_POST, // what to search |
| 37 | 'query' => [ 'post_type' => 'product' ], // scope (search term is added server-side) |
| 38 | 'display' => 'minimal', // or 'detailed' |
| 39 | ], |
| 40 | ] ); |
| 41 | ``` |
| 42 | |
| 43 | Verified contract: |
| 44 | |
| 45 | - `QUERY_CONTROL_ID = 'query'`; the control class `Query extends Control_Select2` ([controls/query.php:12-16](query.php)). |
| 46 | - `'autocomplete'['object']` is one of ([module.php:34-39](module.php)): `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`. |
| 47 | - `'query'` is merged into the WP query scope; `'display'` is `'minimal'` or `'detailed'`; `'by_field' => 'ID'` stores the chosen post ID. |
| 48 | - 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. |
| 49 | |
| 50 | ### Why it doesn't freeze (verified) |
| 51 | |
| 52 | The autocomplete handler **returns early with a `WP_Error` when the search term is empty** ([module.php:211](module.php)) — so nothing runs until the user types. When they do, `autocomplete_query_for_post()` sets `$query['s'] = $data['q']` ([module.php:242](module.php)); the `'posts_per_page' => -1` alongside it ([module.php:241](module.php)) 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 upfr |