{"slug":"elementor-dynamic-tag-ajax-select","title":"elementor-dynamic-tag-ajax-select","summary":"Let an Elementor control (in a Dynamic Tag or a widget)","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-16T14:51:42.116912Z","repo":{"url":"https://github.com/Lonsdale201/wp-agent-skills","stars":22,"forks":2,"license":"MIT","updatedAt":"2026-09-21T19:53:59Z"},"bodyHtml":"<hr>\n<h2>name: elementor-dynamic-tag-ajax-select\ndescription: Let an Elementor control (in a Dynamic Tag or a widget)\npick one item from a large dataset — products, posts, terms, users —\nwithout freezing the editor. A plain Controls_Manager<span>SELECT2 with\noptions preloaded upfront (e.g. all 20k products) hangs the panel;\nthe fix is Elementor Pro's AJAX query control —\n'type' =&gt; QueryControlModule</span>QUERY_CONTROL_ID with an 'autocomplete'\n=&gt; [ 'object' =&gt; QUERY_OBJECT_POST|TAX|AUTHOR|USER|ATTACHMENT, 'query'\n=&gt; [...], 'display' =&gt; 'minimal'|'detailed' ] config. It is\nsearch-scoped server-side (no query runs until the user types), so\ncatalog size is irrelevant. The query control is Pro-only, so\nfeature-detect class_exists( QueryControlModule<span>class ) and degrade\nto a manual ID Controls_Manager</span>TEXT field when Pro is absent. Use\nwhen a tag/widget setting must reference a specific post/product, on\nlarge stores, or when the editor freezes opening a SELECT2.\nmetadata:\nwp-skills-author: \"Soczó Kristóf\"\nwp-skills-contact: \"mailto:lonsdale201@hotmail.com\"\nwp-skills-plugin: \"elementor-pro\"\nwp-skills-plugin-version-tested: \"4.0.7 (free) / 4.0.4 (pro)\"\nwp-skills-php-min: \"7.4\"\nwp-skills-last-updated: \"2026-06-17\"</h2>\n<h1>Elementor: AJAX item picker for tags &amp; widgets (large datasets)</h1>\n<p>When a Dynamic Tag or widget setting must point at <strong>one specific record</strong> out of many — \"this product\", \"that landing page\", \"this author\" — you need a searchable picker. On a small set a preloaded <code>SELECT2</code> 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.</p>\n<h2>The misconception (and why the editor freezes)</h2>\n<blockquote>\n<p>\"I'll list the products in a <code>SELECT2</code> so the user can search them.\"</p>\n</blockquote>\n<pre><code>// ANTI-PATTERN at scale — every product becomes a preloaded &lt;option&gt;\n$options = [];\nforeach ( wc_get_products( [ 'limit' =&gt; -1 ] ) as $p ) {\n    $options[ $p-&gt;get_id() ] = $p-&gt;get_name();   // &lt;-- 20k entries in the panel\n}\n$this-&gt;add_control( 'product_id', [\n    'type'    =&gt; \\Elementor\\Controls_Manager::SELECT2,\n    'options' =&gt; $options,\n] );\n</code></pre>\n<p>A preloaded <code>SELECT2</code> ships all options to the editor up front. That is exactly what the reference plugin's <code>ProductAttributes</code> tag does — but only because attribute taxonomies are a handful (<a href=\"ProductAttributes.php\">ProductAttributes.php:62-74</a>). The same shape over products/posts is what locks the panel. <strong>Preloaded <code>SELECT2</code> is correct only for small, bounded option sets</strong> (a dozen statuses, a few taxonomies).</p>\n<h2>The fix — Elementor Pro's AJAX query control</h2>\n<p>The query control is a <code>SELECT2</code> whose options are fetched <strong>on demand, by search term</strong>, over AJAX. Catalog size is irrelevant because nothing is queried until the user types.</p>\n<pre><code>use ElementorPro\\Modules\\QueryControl\\Module as QueryControlModule;\n\n$this-&gt;add_control( 'product_id', [\n    'label'        =&gt; esc_html__( 'Product', 'myplugin' ),\n    'type'         =&gt; QueryControlModule::QUERY_CONTROL_ID,   // 'query'\n    'options'      =&gt; [],            // empty — filled by AJAX\n    'label_block'  =&gt; true,\n    'autocomplete' =&gt; [\n        'object'  =&gt; QueryControlModule::QUERY_OBJECT_POST,   // what to search\n        'query'   =&gt; [ 'post_type' =&gt; 'product' ],            // scope (search term is added server-side)\n        'display' =&gt; 'minimal',                               // or 'detailed'\n    ],\n] );\n</code></pre>\n<p>Verified contract:</p>\n<ul>\n<li><code>QUERY_CONTROL_ID = 'query'</code>; the control class <code>Query extends Control_Select2</code> (<a href=\"query.php\">controls/query.php:12-16</a>).</li>\n<li><code>'autocomplete'['object']</code> is one of (<a href=\"module.php\">module.php:34-39</a>): <code>QUERY_OBJECT_POST</code> (<code>'post'</code>), <code>QUERY_OBJECT_TAX</code> (<code>'tax'</code>), <code>QUERY_OBJECT_AUTHOR</code> (<code>'author'</code> — users who authored content), <code>QUERY_OBJECT_USER</code> (<code>'user'</code> — all users), <code>QUERY_OBJECT_ATTACHMENT</code> (<code>'attachment'</code>), <code>QUERY_OBJECT_LIBRARY_TEMPLATE</code>.</li>\n<li><code>'query'</code> is merged into the WP query scope; <code>'display'</code> is <code>'minimal'</code> or <code>'detailed'</code>; <code>'by_field' =&gt; 'ID'</code> stores the chosen post ID.</li>\n<li>The server-side AJAX handler is <strong>entirely Pro's</strong> — registered on <code>elementor/ajax/register_actions</code> (<code>pro_panel_posts_control_filter_autocomplete</code>, <code>query_control_value_titles</code>). Your code writes <strong>no</strong> <code>wp_ajax_</code> handler; you only declare the <code>autocomplete</code> config and Pro does the search and the saved-value label resolution.</li>\n</ul>\n<h3>Why it doesn't freeze (verified)</h3>\n<p>The autocomplete handler <strong>returns early with a <code>WP_Error</code> when the search term is empty</strong> (<a href=\"module.php\">module.php:211</a>) — so nothing runs until the user types. When they do, <code>autocomplete_query_for_post()</code> sets <code>$query['s'] = $data['q']</code> (<a href=\"module.php\">module.php:242</a>); the <code>'posts_per_page' =&gt; -1</code> alongside it (<a href=\"module.php\">module.php:241</a>) is harmless because the <code>s</code> search term bounds the result set. This is the inverse of the preloaded <code>SELECT2</code>: the query is small and on-demand, not large and upfront.</p>\n<h2>Using it inside a Dynamic Tag</h2>\n<p>The query control works in a <code>Tag</code>/<code>Data_Tag</code> exactly as in a widget — store the chosen ID in a setting and resolve it in <code>render()</code> / <code>get_value()</code>. Pro's <code>Internal_URL</code> data tag is the canonical example: a <code>type</code> selector plus per-type query controls (<code>post_id</code>, <code>taxonomy_id</code>, <code>attachment_id</code>, <code>author_id</code>), each with its own <code>autocomplete['object']</code> and a <code>condition</code> (<a href=\"internal-url.php\">internal-url.php:72-142</a>):</p>\n<pre><code>$this-&gt;add_control( 'post_id', [\n    'label'        =&gt; esc_html__( 'Search &amp; Select', 'myplugin' ),\n    'type'         =&gt; QueryModule::QUERY_CONTROL_ID,\n    'options'      =&gt; [],\n    'label_block'  =&gt; true,\n    'autocomplete' =&gt; [\n        'object'  =&gt; QueryModule::QUERY_OBJECT_POST,\n        'display' =&gt; 'detailed',\n        'query'   =&gt; [ 'post_type' =&gt; 'any' ],\n    ],\n    'condition'    =&gt; [ 'type' =&gt; 'post' ],\n] );\n\n// …then resolve in get_value()/render():\n$url = get_permalink( (int) $this-&gt;get_settings( 'post_id' ) );\n</code></pre>\n<p>Note: in the bundled reference plugin, <strong>no dynamic tag uses the AJAX query control</strong> — every <code>SELECT2</code> tag (<code>ProductAttributes</code>, membership-plan tags) preloads a small, bounded set. The AJAX pattern there lives in the <strong><code>DynamicAddToCartWidget</code></strong>. For a tag, Pro's <code>Internal_URL</code> is the reference.</p>\n<h2>Graceful degradation when Pro is absent (required)</h2>\n<p><code>QueryControlModule</code> only exists with Pro. Feature-detect and fall back to a manual ID field — the exact pattern in <code>DynamicAddToCartWidget</code>:</p>\n<pre><code>use Elementor\\Controls_Manager;\nuse ElementorPro\\Modules\\QueryControl\\Module as QueryControlModule;\n\nprivate function has_query_control_support(): bool {\n    return class_exists( QueryControlModule::class );   // Pro present?\n}\n\nprivate function product_control_type(): string {\n    return $this-&gt;has_query_control_support()\n        ? QueryControlModule::QUERY_CONTROL_ID\n        : Controls_Manager::TEXT;                        // manual ID entry\n}\n\n// …building the control:\n$control = [ 'label' =&gt; esc_html__( 'Product', 'myplugin' ), 'type' =&gt; $this-&gt;product_control_type() ];\n\nif ( $this-&gt;has_query_control_support() ) {\n    $control['autocomplete'] = [\n        'object'   =&gt; QueryControlModule::QUERY_OBJECT_POST,\n        'query'    =&gt; [ 'post_type' =&gt; 'product' ],\n        'display'  =&gt; 'minimal',\n        'by_field' =&gt; 'ID',\n    ];\n} else {\n    $control['description'] = esc_html__( 'Enter the product ID manually. Activate Elementor Pro for the search picker.', 'myplugin' );\n}\n$this-&gt;add_control( 'product_id', $control );\n</code></pre>\n<p>Verified at <a href=\"DynamicAddToCartWidget.php\">DynamicAddToCartWidget.php:43-53,181-198</a>. Either way the stored value is a post ID, so <code>render()</code>/<code>get_value()</code> resolves it identically regardless of which control produced it.</p>\n<h2>Critical rules</h2>\n<ul>\n<li><strong>Never preload a <code>SELECT2</code> from an unbounded query</strong> (products/posts/users). Preloaded options are for small, fixed sets only.</li>\n<li><strong>Use <code>QUERY_CONTROL_ID</code> + <code>autocomplete</code> for large datasets.</strong> It is search-scoped server-side; size doesn't matter.</li>\n<li><strong>The query control is Pro-only.</strong> Always <code>class_exists( QueryControlModule::class )</code> and degrade to a <code>Controls_Manager::TEXT</code> manual-ID field (or a deliberately bounded <code>SELECT2</code>).</li>\n<li><strong>Write no AJAX handler.</strong> Pro owns <code>pro_panel_posts_control_filter_autocomplete</code> / <code>query_control_value_titles</code>; you only declare <code>autocomplete</code>.</li>\n<li><strong><code>'options' =&gt; []</code></strong> for a query control — options arrive via AJAX; preloading defeats the purpose.</li>\n<li><strong>Pick the right <code>object</code></strong>: <code>author</code> = users who authored content, <code>user</code> = all users; <code>post</code> needs a <code>query.post_type</code> scope; <code>tax</code> searches terms.</li>\n<li><strong>The stored value is an ID</strong> — cast and resolve it (<code>get_permalink( (int) $id )</code>, <code>wc_get_product( (int) $id )</code>) and handle a missing/invalid ID.</li>\n</ul>\n<h2>Common mistakes</h2>\n<pre><code>// WRONG — query control but options preloaded (pointless + slow)\n$this-&gt;add_control( 'id', [\n    'type'    =&gt; QueryControlModule::QUERY_CONTROL_ID,\n    'options' =&gt; $all_products,           // &lt;-- defeats the AJAX control\n] );\n\n// WRONG — hard-requiring Pro; control silently missing on free → no way to set a product\n$this-&gt;add_control( 'id', [ 'type' =&gt; QueryControlModule::QUERY_CONTROL_ID, /* … */ ] );\n// (no class_exists guard → fatal/empty when Pro inactive)\n\n// WRONG — writing your own ajax handler for it\nadd_action( 'wp_ajax_my_product_search', /* … */ );   // &lt;-- unnecessary; Pro handles query control AJAX\n\n// RIGHT — feature-detect, degrade, let Pro do the AJAX\n$type = class_exists( QueryControlModule::class )\n    ? QueryControlModule::QUERY_CONTROL_ID\n    : \\Elementor\\Controls_Manager::TEXT;\n</code></pre>\n<h2>Cross-references</h2>\n<ul>\n<li>Run <strong><code>elementor-dynamic-tag-fields</code></strong> for the control types, <code>Tag</code> vs <code>Data_Tag</code>, and reading settings back.</li>\n<li>Run <strong><code>elementor-dynamic-tag-register</code></strong> for registering the tag and the Pro-feature reality.</li>\n<li>Run <strong><code>wc-product-search-select</code></strong> for the WooCommerce-native product search/select control (a non-Elementor alternative for product pickers).</li>\n</ul>\n<h2>What this skill does NOT cover</h2>\n<ul>\n<li><strong>The query control's full option surface</strong> (custom <code>query</code> args per object type, <code>include_type</code>, sorting) — read <a href=\"module.php\">module.php</a> <code>autocomplete_query_for_*</code>.</li>\n<li><strong>Building a bespoke AJAX select without Pro</strong> — possible via a custom control + <code>wp_ajax_</code>, but out of scope; prefer the manual-ID degrade.</li>\n<li><strong>Query-control <em>filtering</em> for loop/posts widgets</strong> (the <code>Group_Control</code> query side) — this skill is about single-item pickers.</li>\n<li><strong>Caching/transients for the search results</strong> — Pro's handler runs uncached per keystroke; debounce/min-length is Pro's select2 default.</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li>Query control module: <a href=\"module.php\">wp-content/plugins/elementor-pro/modules/query-control/module.php</a> — <code>QUERY_CONTROL_ID</code>/<code>QUERY_OBJECT_*</code> (29,34-39), empty-term early return (211), <code>autocomplete_query_for_post</code> sets <code>s</code> (233-242), AJAX action registration (~1012-1024).</li>\n<li>Query control class: <a href=\"query.php\">wp-content/plugins/elementor-pro/modules/query-control/controls/query.php:12-16</a> — <code>class Query extends Control_Select2</code>.</li>\n<li>AJAX query control in a dynamic tag: <a href=\"internal-url.php\">wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/internal-url.php:72-142</a>.</li>\n<li>Graceful-degradation widget: <a href=\"DynamicAddToCartWidget.php\">wp-content/plugins/dynamic-elementor-extension-main/modules/widgets/dynamic/DynamicAddToCartWidget.php:43-53,181-198</a>.</li>\n<li>Bounded preloaded SELECT2 (correct small-set use): <a href=\"ProductAttributes.php\">wp-content/plugins/dynamic-elementor-extension-main/dynamic-tags/woo-tags/ProductAttributes.php:62-74</a>.</li>\n<li>Official documentation: <a href=\"https://developers.elementor.com/docs/dynamic-tags/\">https://developers.elementor.com/docs/dynamic-tags/</a></li>\n</ul>\n","files":[{"path":"SKILL.md","sizeBytes":11706,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-16T14:55:05.863263Z","sha256":"D9B053F85E79761F01B75E0BA9C69636955BF25B696E8B2C4D0B2BB2FDE77433","sizeBytes":4464},"review":null,"source":{"repositoryUrl":"https://github.com/Lonsdale201/wp-agent-skills","path":"elementor/elementor-dynamic-tag-ajax-select","license":"MIT","commit":"8820ff3c301066297e696611e3bc4ebeb47d1851","subtreeSha":"D2C17C1D13DD23D2254FA4891FB9D7B9E18A85985A7A66C4072BB56B51B488CD","lastSyncedAt":"2026-09-22T13:51:11.366991Z"},"reviewedAt":"2026-09-16T15:09:48.514208Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/elementor/elementor-dynamic-tag-ajax-select"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart"},{"target":"git","command":"git clone https://github.com/Lonsdale201/wp-agent-skills.git"}]}