wordpress-elementor-dev
Expert WordPress and Elementor Pro development skill. Use this whenever the user asks about WordPress theme development, Elementor Pro customization, plugin architecture, custom post types, hooks/filters, Loop Grids, Dynamic Tags, Theme Builder templates, off-canvas UIs, custom C
Install
npx skills add https://github.com/guramzhgamadze/WordPress-Elementor-Skill/tree/main/wp-elementor-skill
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install guramzhgamadze-wordpress-elementor-skill@llmmart
git clone https://github.com/guramzhgamadze/WordPress-Elementor-Skill.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole guramzhgamadze/wordpress-elementor-skill collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
WordPress & Elementor Pro — Skill Router
A complete procedural guide for producing production-grade WordPress and Elementor Pro code. This file is the router — read it first, then load the relevant sub-file(s) for the task at hand.
Sub-file Map — Read the Right File for Each Task
| Task type | Read this file |
|---|---|
| Plugin scaffold, child theme, code placement, CPT, AJAX handler | scaffolding.md |
| PHP standards, sanitization, escaping, nonces, WP_Error, transients, password hashing | php-standards.md |
| JavaScript standards, enqueue API, defer/async, wp_add_inline_script | js-css-standards.md |
| CSS standards, BEM, design tokens, Elementor CSS selectors | js-css-standards.md |
| Elementor custom widget, Dynamic Tags, Loop Grid, Form actions, Theme Builder conditions | elementor-patterns.md |
| Elementor extension points — custom form fields, theme locations, injecting controls into native widgets, adding a TAB to the editor's Elements panel, Finder/context-menu, hooks reference, deprecations | elementor-extending.md |
| WooCommerce HPOS, order API, template overrides, Loop Grid for products | woocommerce.md |
| REST API endpoints, schema, permission callbacks | rest-api.md |
| Off-canvas UI, off-canvas accessibility, focus trap | offcanvas-ui.md |
| Performance checklists (frontend + backend), speculative loading, IE conditional comments | performance.md |
| Accessibility checklist, WCAG 2.2 AA, ARIA patterns | performance.md |
Hard-won production gotchas — widget lifecycle fatals, content_template() escaping, CSS-in-Elementor footguns, transactional email, wp.org review/packaging, embedding apps, AJAX/loop re-rendering, dynamic CSS in loops, Swiper on optimised sites, holding a login without counting it as failed |
field-notes.md |
| wordpress.org submission — the 18 Directory Guidelines, Plugin Check 2.1.0 categories/usage, review process, required headers/readme | wp-org-guidelines.md |
| Subversion (SVN) — deploying/tagging a release on the wordpress.org plugin/theme SVN (trunk/tags/assets), the daily work cycle, branching/merging, properties, repo admin | svn/svn.md (self-contained sub-bundle; svn/references/ goes deeper) |
Debugging & static analysis — PHPCS+WPCS, PHPStan, Plugin Check, WP_DEBUG/Query Monitor, Elementor Safe Mode/cache, symptom→cause table |
debugging.md |
Common WordPress APIs — admin settings page (Settings + Options API), register_meta, roles/capabilities, WP-Cron, internationalization (i18n) |
wordpress-apis.md |
Database layer (MariaDB/MySQL) — custom tables + dbDelta(), charsets & the varchar(191) index rule, indexes, EXPLAIN, MariaDB↔MySQL portability, sql_mode, full-text |
mariadb.md |
Widget Boilerplates — Load when building a widget of that type
| Widget type | Read this file |
|---|---|
| Button / CTA with icon and link | widget-button.md |
| Container / layout wrapper / section / card shell | widget-container.md |
| Image with caption, link, lightbox | widget-image.md |
| Heading / title / HTML tag selector | widget-heading.md |
| Rich text / WYSIWYG body content | widget-text-editor.md |
| Video embed (YouTube, Vimeo, self-hosted) | widget-video.md |
| Widget that renders a saved Elementor template by ID | widget-elementor-template.md (SELECT2, get_builder_content_for_display(), Dynamic Tags context, CSS timing) |
| Widget whose markup lives in a separate PHP template file | widget-php-template.md (Strategies A/B/C, load_template(), ob_start, path-traversal safety) |
| Divider / horizontal rule with optional text or icon | widget-divider.md |
| Spacer / vertical gap | widget-spacer.md |
| Single standalone icon with optional link | widget-icon.md |
| Icon + title + description box | widget-icon-box.md |
| Image + title + description box | widget-image-box.md |
| Image grid / gallery | widget-image-gallery.md |
| Image slider / carousel | widget-image-carousel.md |
| Bullet list with icons per item | widget-icon-list.md |
| Animated number counter | widget-counter.md |
| Percentage progress bar | widget-progress.md |
| Customer quote / testimonial | widget-testimonial.md |
| Tabbed content panels | widget-tabs.md |
| Accordion — one panel open at a time | widget-accordion.md |
| Toggle — multiple panels open simultaneously | widget-toggle.md |
| Social media icon links row | widget-social-icons.md |
| Colored alert / notice box | widget-alert.md |
| Audio player (SoundCloud / self-hosted) | widget-audio.md |
| WordPress shortcode output | widget-shortcode.md |
| Raw custom HTML / JS / CSS embed | widget-html.md |
| Named anchor for in-page navigation | widget-menu-anchor.md |
| WordPress registered sidebar output | widget-sidebar.md |
| WordPress read more tag | widget-read-more.md |
| Google Maps embed | widget-google-maps.md |
| Decorative star rating display | widget-star-rating.md |
| Schema-ready structured rating | widget-rating.md |
| Text following a curved / custom SVG path | widget-text-path.md |
| Nested Tabs / Nested Accordion (each panel is a Container holding any widgets) | widget-nested.md |
Always read the relevant sub-file before writing code. For tasks that span multiple
areas (e.g. a WooCommerce widget with custom REST endpoint), read all relevant sub-files.
For a widget task, read BOTH the widget boilerplate file AND elementor-patterns.md.
For any custom widget, plugin, or wp.org-bound work, also skim field-notes.md — it
catches the lint-passing, review-failing, site-down mistakes the topic files don't dwell on.
0. Golden Rules (Never Violate)
These override everything in all sub-files:
- Native APIs first — WordPress core hook before plugin; Elementor API before template override.
- Sanitize in, escape out — Every input sanitized. Every output escaped. No exceptions.
- Prefix everything — All functions, classes, constants, hooks, and CSS classes use a project-specific prefix.
- State your placement — Every code response must declare exactly where the code lives.
- No over-clarifying — Only ask a clarification question if the missing info would materially change the code output. Otherwise, state your assumption and proceed.
- NEVER hardcode visual settings in widgets — Every visual property (colors, fonts, sizes, spacing, backgrounds, borders, shadows, alignment) MUST be exposed as a standard Elementor control in the editor panel. Users control appearance via the toolbar — not by editing code. See §5 "Mandatory Widget Controls" below for the required controls checklist.
- Name for the directory from day one — Naming is decided first and is effectively
irreversible, so get it right before writing code. The plugin slug/name must NOT start with
a trademark you don't own —
"CRM for WooCommerce", never"WooCommerce CRM"(Directory Guideline 17). The text domain must exactly equal the plugin slug. The wp.org slug is permanent, and a widget'sget_name()is sticky (stored in every page's_elementor_data— renaming it breaks placed widgets). Choose the public name, slug, text domain, and code prefix (Rule #3) together, once. This is distinct from Rule #3: that governs internal code symbols; this governs the public identity. See wp-org-guidelines.md (Guidelines 12/16/17) and field-notes.md §1 (stickyget_name()).
1. Default Assumptions
Quickly assess — only ask if the answer would change the code:
| Info needed | Ask only if... |
|---|---|
| Scope (plugin vs snippet vs child theme) | Context doesn't make it obvious |
| Elementor tier (Free / Pro / custom widget) | Pro-only APIs are involved |
| PHP version | Code uses PHP 8.3+ features like typed class constants, or 8.4+ features like property hooks |
| WooCommerce / ACF / WPML present | Integration with those systems is required |
Default stack when not stated (full release-by-release history lives in CHANGELOG.md — keep volatile version-tracking out of this router):
| Component | Version | Notes |
|---|---|---|
| WordPress | 7.1 | "Mary Lou", released Aug 19, 2026 (7.0 "Armstrong" was May 20; the 7.0 branch ended at 7.0.4, Aug 12). Minimum PHP 7.4 (7.2/7.3 dropped — sites still on them stay pinned to 6.9.x). No multisite assumed. 7.1 makes the post editor iframe unconditional — see below. |
| PHP | 8.3 recommended | 7.4 = minimum. 8.4 / 8.5 = "beta support" (possible deprecation notices). 8.2 fully compatible but no longer the recommended default. |
| Elementor (free + Pro) | 4.2+ | Separate plugins with independent version numbers — currently free 4.2.3 / Pro 4.2.2 (both Aug 19, 2026). 4.0.0 (Mar 30, 2026) made the Atomic Editor stable + default for new installs; 4.2.0 (Jul 20) added Atomic Grid (free) and Atomic Loop (Pro). ⚠️ Elementor's header still reads "Tested up to: 7.0.4" — it has not declared WP 7.1 support. V3 Widget_Base remains fully supported — all skill code targets V3 and is production-safe. |
| WooCommerce | 11.0+ | 11.0 (Aug 4, 2026) REMOVED the product editor beta and bumped to Action Scheduler 4.0.0 (breaking dedup change). HPOS default-on since 8.2; 10.7 disabled HPOS "sync on read"; 10.9 defers Store API draft-order creation — see woocommerce.md. |
Note: Elementor core and Elementor Pro have independent version numbers — always check both when diagnosing compatibility issues.
WordPress 7.1 — what changed for plugin / Elementor devs
WP 7.1 "Mary Lou" shipped August 19, 2026 (WordCamp US). Unlike 7.0, this release contains four changes that can break a shipped plugin — they are listed first. No PHP or WordPress minimum changed.
🔴 Breaking — audit your plugin for these:
- The post editor is now ALWAYS in an iframe — including on sites registering legacy meta
boxes, which was the last remaining escape hatch. Editor JavaScript that reaches for the
global
document/windownow targets the wrong document. Use the canvas element'sownerDocumentanddefaultViewinstead:
Injected// ❌ breaks in 7.1 — this is the OUTER document, not the editor canvas document.querySelector( '.my-block' ).classList.add( 'ready' ); // ✅ resolve the document from a node you already own inside the canvas const doc = myCanvasNode.ownerDocument; const view = doc.defaultView; // the iframe's `window` doc.querySelector( '.my-block' )?.classList.add( 'ready' ); view.requestAnimationFrame( … );<style>/<link>must go into the canvas document too, or it styles nothing. Elementor's own editor is unaffected (it is not the block editor) — this hits block registrations, meta boxes, and any block-editor integration your plugin ships. __next40pxDefaultSizeis now a no-op on@wordpress/components— remove the prop entirely; there is no replacement. Form controls render at 40px unconditionally.- List-table markup changed: the row header moved from the checkbox column to the title
column. Any CSS/JS selector keyed on
th.check-column(or assuming the row header's position) breaks — audit custom admin list tables and column callbacks. - jQuery UI updated to 1.14.2. Test anything depending on jQuery UI behaviour or styling.
🟢 New and useful:
- SVG Icon API (public in 7.1) — register icons and whole collections, render server-side,
read over REST:
wp_register_icon_collection(),wp_register_icon(),wp_get_icon(). ⚠️ SVGs pass through a conservative allowlist — only<svg>,<path>and<polygon>survive. A<g>,<circle>,<rect>or<use>in your icon is stripped silently, so convert shapes to paths before registering. (This is a core API — Elementor widgets still ship their own SVGs; seeelementor-extending.md§8 and the "don'twp_kses()inline SVG" rule infield-notes.md§6.) - Abilities API matured:
wp_get_abilities()filtering, execution-lifecycle hooks, custom validation, a unifiedpublicexposure flag, and client-compatible JSON-Schema preparation. Registration timing rules from 7.0 are unchanged (see the 7.0 section below). - Global Styles /
theme.json: responsive style variations with configurable breakpoints, pseudo-state styling (:hover/:focus/:active), andtext-shadowsupport. - Media: client-side image processing in the browser via WebAssembly (compression/resizing no longer hit the server), infinite scroll in the Media Library, and registration of multiple image sizes at once. Multisite now enforces upload limits on media sideloading.
- Persistent admin bar across editor screens — review any custom toolbar nodes for behaviour in the editor context.
- Design System: semantic design tokens for theming admin UI (relevant if you build admin or
editor panels — compare the Elementor panel tokens in
elementor-extending.md§5). - DataViews / DataForm APIs matured for data-driven admin interfaces.
- React stays on 18.3 — React 19 was deferred again. Don't bundle
react/jsx-runtimeyourself, and avoid string refs /defaultProps, which fail under the experimental React 19 flag. - Conditional block-CSS loading can affect remote content pulls — verify if you render blocks outside a normal page request.
Sources: wordpress.org/news/2026/08/mary-lou/ · make.wordpress.org/core/2026/08/05/wordpress-7-1-field-guide/ · developer.wordpress.org/news/2026/08/whats-new-for-developers-august-2026/
WordPress 7.0 — what changed for plugin / Elementor devs
WP 7.0 "Armstrong" shipped May 20, 2026 (delayed from the original April 9 target while the RTC storage layer was redesigned — see below). Everything below is opt-in and non-breaking; most plugin/Elementor work is unaffected. It remains relevant because 7.1 changed none of it.
- Minimum PHP is now 7.4 (7.2/7.3 dropped). The skill's recommended baseline stays
PHP 8.3. Bump your plugin's
Requires PHPheader to 7.4 only once you target WP 7.0+ exclusively. No new DB minimum is enforced;wordpress.org/about/requirements/recommends MariaDB 10.11+ or MySQL 8.0+ (the MariaDB floor was raised from 10.6; re-verified Aug 2026). - Real-Time Collaboration (RTC): simultaneous multi-author block editing (CRDT-based, via
an HTTP-polling sync provider — not WebRTC). Data is stored in a dedicated core database
table; an earlier
wp_post_meta/wp_sync_storagedesign was rejected, and building the table is what pushed the release from April to May. Plugin impact: scope everyWP_Query/get_posts()with an explicitpost_typeso internal core post types never leak into your results — do not hardcode any internal RTC type name. TheWP_ALLOW_COLLABORATIONconstant lets hosts swap the sync transport. - WP AI Client: provider-agnostic PHP + JS AI API —
wp_ai_client_prompt( $prompt )->generate_text(). Guard withfunction_exists( 'wp_ai_client_prompt' ). - Abilities API:
wp_register_ability()(PHP, since WP 6.9) plus a JS counterpart in 7.0. Use'meta' => ['show_in_rest' => true]to expose via REST. Register ONLY inside thewp_abilities_api_inithook (core renamed the feature plugin'sabilities_api_init; categories go onwp_abilities_api_categories_init) or you get_doing_it_wrong; acategoryis required and must already be registered. Calling it also sets your realRequires at leastfloor to 6.9 — Plugin Check errors on the header andfunction_exists()guards don't count (seedebugging.md§1). - Connectors UI (Settings → Connectors) for managing AI provider credentials, and a Command Palette in wp-admin.
- Iframed editor was still conditional in 7.0 — 7.1 makes it unconditional. See the 7.1
section above;
"apiVersion": 3inblock.jsonis now table stakes, not preparation.
Sources: make.wordpress.org/core/2026/01/09/dropping-support-for-php-7-2-and-7-3/ · make.wordpress.org/core/2026/04/22/wordpress-7-0-release-party-updated-schedule/ · wordpress.org/about/requirements/
📌 PHP support labels (unchanged in WP 7.1): PHP 7.4–8.3 fully compatible; 8.3 recommended; 8.4 (WP 6.7+) and 8.5 (WP 6.9+) carry a "beta support" label (possible deprecation notices). Source: make.wordpress.org/core/handbook/references/php-compatibility-and-wordpress-versions/
✅ Elementor 4.x status (current: free 4.2.3 / Pro 4.2.2, both Aug 19, 2026): Elementor 4.0.0 (Mar 30, 2026, free + Pro) made the Atomic Editor stable and the default for new installs and added Atomic Forms, Pro Interactions, and Component creation. Updating to 4.x leaves existing sites untouched — V3 widgets and V4 Atomic Elements coexist on the same page; Atomic features are toggled at WP Admin → Elementor → Editor → Settings.
There is still NO third-party API for building Atomic Elements, and Elementor has said so explicitly — they will not release one soon and advise against integrating with Atomic internals until they announce it (elementor/elementor GitHub Discussion #32950). Atomic Elements are documented only as a data structure (developers.elementor.com/docs/data-structure/atomic-elements). So continue using V3
Widget_Basefor all third-party widgets — it is the correct, production-safe API and all skill code targets it. (Re-verified Aug 23, 2026 — unchanged since the July check.)⚠️ Elementor has not declared WP 7.1 compatibility yet. As of free 4.2.3 / Pro 4.2.2 the
Tested up toheader reads 7.0.4, three days after WP 7.1 shipped. That is normal lag, not a known incompatibility — but when a client reports editor breakage on a fresh 7.1 site, check Elementor's currentTested up tobefore debugging your own code.V4 Atomic Elements that now ship by default (awareness only — not third-party-buildable yet): Div Block & Flexbox Container (layout); Atomic Grid — advanced row/column layouts (free 4.2.0, Jul 20, 2026); Atomic Heading, Paragraph, Image, Button, Video, SVG; Atomic Tabs; Atomic Forms (Pro) with composable fields — Label, Input, Textarea, Checkbox, Submit, plus Radio, Select, Date Picker, Time Picker, and File Upload (added in Pro 4.1.0, May 26, 2026); and Atomic Loop — dynamic content layouts, the V4 counterpart of the Pro Loop Grid (Pro 4.2.0, Jul 20, 2026). These are end-user elements; building custom atomic elements still awaits the finalized V4 extension docs — keep targeting V3
Widget_Baseuntil then. Source: elementor.com/products/website-builder/v4-faq/ · developers.elementor.com/elementor-editor-4-0-developers-update/ · elementor.com/pro/changelog/ · github.com/elementor/elementor/releases
🗓️ Release-by-release history (betas, RCs, point releases) lives in
CHANGELOG.md. Keep this router focused on durable guidance; update version facts in the table above and inCHANGELOG.md, not scattered across the sub-files.
2. Architecture Decision Tree
Run through this mentally before writing a single line:
Does a WordPress core hook (add_action/add_filter) solve it?
YES → Use the hook. No plugin needed. Place in child theme functions.php
or Elementor Custom Code.
NO → Does Elementor's PHP/JS API solve it?
YES → Extend via Elementor hooks, Dynamic Tags, or Widget_Base.
Register via elementor/widgets/register or elementor/dynamic_tags/register.
NO → Is this logic reusable across themes or sites?
YES → Scaffold a dedicated plugin (see scaffolding.md).
NO → Child theme functions.php or Elementor Custom Code block.
Is a WooCommerce override needed?
→ Use Elementor Loop Grid + custom query filter BEFORE touching template files.
→ Only override woocommerce/ templates as an absolute last resort.
Is this a REST API endpoint?
→ Always register via register_rest_route() inside a plugin, never in functions.php.
3. Mandatory Output Format
Every single code response must follow this structure — no exceptions:
📍 PLACEMENT
Exact file path or Elementor hook location.
e.g. /wp-content/plugins/myplugin/includes/class-myplugin-hooks.php
Elementor → Site Settings → Custom Code → wp_footer
⚙️ REQUIRES
WordPress X.X+ | PHP X.X+ | Elementor Pro X.X+ | ACF X.X+ | WooCommerce X.X+
(list only what the code actually depends on)
💡 WHY THIS APPROACH
One paragraph: which branch of the §2 decision tree was taken and why.
📋 CODE
Complete, commented, deployment-ready code block — no truncation, no omissions.
🔧 INTEGRATION NOTES (include when relevant)
Any manual steps required: flush rewrite rules, set Query ID in Elementor editor,
activate plugin, clear Elementor cache, etc.
4. Quick Reference — Pattern Index
| Task | Approach | Sub-file |
|---|---|---|
| Plugin scaffold | Singleton + hooks class + assets class + HPOS declaration | scaffolding.md |
| Child theme setup | wp_enqueue_style parent + child in functions.php |
scaffolding.md |
| Custom Post Type + Taxonomy | register_post_type() + register_taxonomy() in plugin |
scaffolding.md |
| Secure AJAX handler | wp_ajax_ hooks + nonce verify + wp_send_json_* |
scaffolding.md |
| PHP sanitization / escaping | wp_unslash() + sanitize_* + esc_* patterns |
php-standards.md |
| Transient caching | get_transient / set_transient |
php-standards.md |
| External API call + WP_Error | wp_remote_get() + WP_Error pattern |
php-standards.md |
| Admin settings page | Settings API + register_setting + sanitize_callback + settings_fields |
wordpress-apis.md |
| Store plugin options | Options API + explicit boolean autoload (WP 6.6+) |
wordpress-apis.md |
| Custom field exposed to REST / Elementor | register_post_meta + show_in_rest |
wordpress-apis.md |
| Scheduled / background task | WP-Cron (wp_schedule_event) + Action Scheduler for heavy jobs |
wordpress-apis.md |
| Create a custom DB table | dbDelta() + $wpdb->prefix + get_charset_collate(); strict formatting rules |
mariadb.md |
| "Specified key was too long" / emoji not saving | utf8mb4 + index string columns at varchar(191) |
mariadb.md |
| Slow query / missing index | EXPLAIN (type: ALL, key: NULL, Using filesort) + leftmost-prefix rule |
mariadb.md |
| SQL that must run on MariaDB and MySQL | Plain standard SQL; JSON is stored/compared differently — don't query inside it | mariadb.md |
| Make a plugin translatable | i18n functions + text-domain = slug + WP 6.7 init-timing rule |
wordpress-apis.md |
| Ship a translation for a wp.org-hosted plugin | .pot only in the zip; translate.wordpress.org → language packs (90% gate). Never bundle .mo / call load_plugin_textdomain() |
wordpress-apis.md |
| Style UI text for non-Latin locales | Caseless scripts + text-transform/letter-spacing; don't size to English string length |
js-css-standards.md |
| Test that translations actually load | Set the site locale before bootstrap; unload_textdomain() in WP-CLI; validate placeholders |
debugging.md |
| Deploy / tag a plugin release on wp.org SVN | svn co → copy to trunk/ → svn cp trunk tags/X.Y.Z → svn ci (assets/ is a sibling) |
svn/svn.md |
| Any SVN task (checkout, commit, branch, merge, conflicts, repo admin) | Subversion sub-bundle | svn/svn.md |
| WP 6.8 password hashing | wp_check_password() + wp_password_needs_rehash() |
php-standards.md |
| WP 6.8 app password / key hashing | wp_fast_hash() + wp_verify_fast_hash() (BLAKE2b) |
php-standards.md |
| JS standards + enqueue defer/async | IIFE + WP 6.3+ enqueue API | js-css-standards.md |
| PHP → JS data passing | wp_add_inline_script() with wp_json_encode() |
js-css-standards.md |
| CSS BEM + design tokens | Scoped tokens, 8pt spacing, fluid type | js-css-standards.md |
| Custom Elementor widget | Widget_Base + all required methods |
elementor-patterns.md |
| Elementor Dynamic Tag | Tag class + elementor/dynamic_tags/register |
elementor-patterns.md |
| Elementor Loop Grid query | elementor/query/ filter |
elementor-patterns.md |
| ACF field in Elementor | Dynamic Tag extending \Elementor\Core\DynamicTags\Tag |
elementor-patterns.md |
| Elementor Pro Form action | Action_Base + field iteration |
elementor-patterns.md |
| Theme Builder custom condition | Condition_Base + elementor/theme/register_conditions |
elementor-patterns.md |
| Elementor Pro custom form field | Field_Base + elementor_pro/forms/fields/register |
elementor-extending.md |
| Theme Builder custom location | register_location + elementor_theme_do_location() |
elementor-extending.md |
| Add a control to a NATIVE Elementor widget | elementor/element/{el}/{section}/before_section_end |
elementor-extending.md |
| Filter a native widget's output | elementor/widget/render_content filter |
elementor-extending.md |
| Add a TAB to the editor panel (like an SEO plugin) | elementorV2.editorElementsPanel.injectTab() + elementor/editor/before_enqueue_scripts |
elementor-extending.md |
| WooCommerce HPOS compatibility | FeaturesUtil::declare_compatibility() + wc_get_order() |
woocommerce.md |
| WooCommerce loop | Loop Grid + elementor/query/ filter |
woocommerce.md |
| Custom REST endpoint | register_rest_route() + schema callback in plugin |
rest-api.md |
| Off-canvas filter panel | BEM CSS + ARIA JS + Custom Code block | offcanvas-ui.md |
| Performance checklist | Frontend + backend checklists | performance.md |
| Accessibility checklist | WCAG 2.2 AA patterns | performance.md |
| Elementor dependency check | Requires Plugins: elementor header + did_action fallback |
scaffolding.md |
| Widget with button/CTA | Controls + render matching native Button widget | widget-button.md |
| Widget with container/layout | Controls + render matching native Container | widget-container.md |
| Widget with image | Group_Control_Image_Size + get_attachment_image_html() |
widget-image.md |
| Widget with heading | TEXTAREA + header_size tag selector | widget-heading.md |
| Widget with rich text | WYSIWYG + wp_kses_post output | widget-text-editor.md |
| Widget with video embed | Source SELECT + overlay + aspect ratio | widget-video.md |
| Widget rendering saved Elementor template | get_builder_content_for_display() + SELECT2 | widget-elementor-template.md |
| Widget with PHP template file | load_template() + locate_template() strategies A/B/C | widget-php-template.md |
| Widget with divider line | Style + width + optional text/icon element | widget-divider.md |
| Widget with spacer gap | Single responsive SLIDER | widget-spacer.md |
| Widget with single icon | ICONS control + size + color tabs | widget-icon.md |
| Widget with icon + text box | Icon + title + description pattern | widget-icon-box.md |
| Widget with image + text box | Image + title + description pattern | widget-image-box.md |
| Widget with image grid | GALLERY control + Group_Control_Image_Size | widget-image-gallery.md |
| Widget with image slider | GALLERY + Swiper + navigation controls | widget-image-carousel.md |
| Widget with icon bullet list | REPEATER + icon + text + optional link | widget-icon-list.md |
| Widget with animated counter | Number + prefix/suffix + duration | widget-counter.md |
| Widget with progress bar | Percentage SLIDER + bar styling | widget-progress.md |
| Widget with testimonial quote | Content + image + name + job title | widget-testimonial.md |
| Widget with tabbed panels | REPEATER tabs + horizontal/vertical type | widget-tabs.md |
| Widget with accordion | REPEATER + single-open collapse pattern | widget-accordion.md |
| Widget with toggle panels | REPEATER + multi-open toggle pattern | widget-toggle.md |
| Widget with social icons | REPEATER + brand icons + links | widget-social-icons.md |
| Widget with alert/notice box | Type SELECT + title + description + dismiss | widget-alert.md |
| Widget with audio player | SoundCloud URL + autoplay options | widget-audio.md |
| Widget outputting shortcode | TEXTAREA + do_shortcode() | widget-shortcode.md |
| Widget with raw HTML embed | CODE control + unescaped output | widget-html.md |
| Widget as named anchor | TEXT ID + sanitize_html_class() | widget-menu-anchor.md |
| Widget outputting sidebar | Registered sidebar SELECT + dynamic_sidebar() | widget-sidebar.md |
| Widget with read more tag | No controls — WordPress $more global | widget-read-more.md |
| Widget with Google Maps | Address TEXT + zoom SLIDER + iframe | widget-google-maps.md |
| Widget with star rating display | Scale + rating number + icon style | widget-star-rating.md |
| Widget with schema rating | Icon count + fractional rating + gap | widget-rating.md |
| Widget with curved/path text | <svg> + <textPath> + unique path id |
widget-text-path.md |
| Nested Tabs / Accordion widget | Widget_Nested_Base + print_child() + container panels |
widget-nested.md |
5. Mandatory Widget Controls — No Hardcoded Visuals (NEVER VIOLATE)
This section is MANDATORY for every custom Elementor widget. Whenever you build a widget, every visual property must be an Elementor control — NEVER a hardcoded CSS value. Users control appearance from the editor panel/toolbar, not by editing source code.
The Rule
NEVER hardcode any of the following in PHP render(), in static CSS, or in
content_template() output:
- Colors (text, background, border, shadow)
- Typography (font family, size, weight, line-height, letter-spacing, transform)
- Spacing (padding, margin, gap)
- Sizing (width, height, min/max values)
- Borders (style, width, color, radius)
- Shadows (box-shadow, text-shadow)
- Backgrounds (color, gradient, image)
- Alignment / positioning
- Opacity, transitions, hover effects
ALL of the above must use Elementor controls with selectors that inject CSS dynamically.
The only exceptions are structural CSS (display, position, overflow) required for the widget
layout to function at all — and even these should use controls when there is a user-facing
choice (e.g. flex-direction toggle).
⚠️ "No hardcoded visuals" does NOT mean "put a
defaulton every control." Elementor emits adefaultexactly like a user-set value, so a colour default paints on every install and clearing the swatch only restores it — the user cannot turn it off. Elementor core carries nodefaulton colour controls at all (includes/widgets/heading.php:title_coloruses a'global'reference;title_hover_coloris entirely empty). Follow that split:
- Sizes, spacing, alignment, toggles, tags, labels → give a real
'default'.- Colours → leave empty. Put the resting look in your stylesheet's
var(--token, fallback)and let the control override it. For state colours (hover / active / selected / current) also write a direct CSS property, never a custom property, and keep that state colour-free in your CSS — otherwise "empty" still paints the fallback.See
field-notes.md§4 for the full trap, plus the theme-specificity andaccent-colorcases.
Required Controls Checklist — Apply to Every Widget
When building a widget, include ALL controls that apply to its visual elements. Use this checklist as a mandatory gate:
| Visual property | Required Elementor control | Tab |
|---|---|---|
| Text content | TEXT, TEXTAREA, or WYSIWYG + 'dynamic' => ['active' => true] |
TAB_CONTENT |
| Typography (any text element) | add_group_control( Group_Control_Typography::get_type() ) |
TAB_STYLE |
| Text color | COLOR control with selectors |
TAB_STYLE |
| Text alignment | add_responsive_control() with CHOOSE (left/center/right/justify) |
TAB_STYLE or TAB_CONTENT |
| Background | add_group_control( Group_Control_Background::get_type() ) |
TAB_STYLE |
| Border | add_group_control( Group_Control_Border::get_type() ) |
TAB_STYLE |
| Border radius | add_responsive_control() with DIMENSIONS + 'selectors' |
TAB_STYLE |
| Box shadow | add_group_control( Group_Control_Box_Shadow::get_type() ) |
TAB_STYLE |
| Text shadow | add_group_control( Group_Control_Text_Shadow::get_type() ) |
TAB_STYLE |
| Padding | add_responsive_control() with DIMENSIONS |
TAB_STYLE |
| Margin | add_responsive_control() with DIMENSIONS |
TAB_STYLE |
| Width / Height | add_responsive_control() with SLIDER |
TAB_STYLE |
| Spacing / Gap | add_responsive_control() with SLIDER |
TAB_STYLE |
| Image | MEDIA + add_group_control( Group_Control_Image_Size::get_type() ) |
TAB_CONTENT |
| CSS Filters (if image/element) | add_group_control( Group_Control_Css_Filter::get_type() ) |
TAB_STYLE |
| Hover state | Duplicate color/background/shadow controls inside 'section_style_hover' with selectors targeting :hover |
TAB_STYLE |
| Transition duration | SLIDER (seconds) with selectors => ['transition-duration'] |
TAB_STYLE |
| Link | URL control with 'dynamic' => ['active' => true] |
TAB_CONTENT |
| Icon | ICONS control with fa4compatibility |
TAB_CONTENT |
| HTML tag | SELECT (h1–h6, div, span, p) |
TAB_CONTENT |
Example — Correct vs Incorrect
// ❌ WRONG — hardcoded color and font-size
protected function render(): void {
$settings = $this->get_settings_for_display();
echo '<h2 style="color: #e94560; font-size: 24px;">'
. esc_html( $settings['title'] ) . '</h2>';
}
// ✅ CORRECT — all visuals controlled from the panel via selectors
protected function register_controls(): void {
// ... Content section with title TEXT control ...
$this->start_controls_section( 'section_title_style', [
'label' => esc_html__( 'Title Style', 'myplugin' ),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
] );
$this->add_control( 'title_color', [
'label' => esc_html__( 'Color', 'myplugin' ),
'type' => \Elementor\Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .myplugin-widget__title' => 'color: {{VALUE}};',
],
] );
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'title_typography',
'selector' => '{{WRAPPER}} .myplugin-widget__title',
]
);
$this->add_responsive_control( 'title_align', [
'label' => esc_html__( 'Alignment', 'myplugin' ),
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ],
'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ],
],
'selectors' => [
'{{WRAPPER}} .myplugin-widget__title' => 'text-align: {{VALUE}};',
],
] );
$this->add_responsive_control( 'title_spacing', [
'label' => esc_html__( 'Bottom Spacing', 'myplugin' ),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => [ 'px', 'em', 'rem' ],
'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
'selectors' => [
'{{WRAPPER}} .myplugin-widget__title' => 'margin-bottom: {{SIZE}}{{UNIT}};',
],
] );
$this->end_controls_section();
}
protected function render(): void {
$settings = $this->get_settings_for_display();
// ✅ No inline styles — all visuals come from Elementor's selectors
$this->add_render_attribute( 'title', 'class', 'myplugin-widget__title' );
$this->add_inline_editing_attributes( 'title' );
echo '<h2 ' . $this->get_render_attribute_string( 'title' ) . '>'
. esc_html( $settings['title'] ) . '</h2>';
}
add_render_attribute() and add_inline_editing_attributes() — Always Use
The official Elementor API for building HTML attributes is $this->add_render_attribute().
Always use it instead of manually concatenating class/id/aria attributes in render().
Pair with $this->add_inline_editing_attributes() for any text field that supports live
editing in the Elementor editor panel.
Outputting the built attributes — pick by context: use
get_render_attribute_string( 'key' ) when you are concatenating into a string (as in the
echo '<h2 ' . ... . '>' example above), and print_render_attribute_string( 'key' ) when
you are echoing directly inside a ?> … <?php HTML block (e.g.
<h2 <?php $this->print_render_attribute_string( 'title' ); ?>>). Both are correct Elementor
APIs — print_* simply echoes what get_* returns. The widget sub-files use the print_*
form inside their HTML templates.
Source: developers.elementor.com/docs/widgets/rendering-html-attribute/ Source: developers.elementor.com/docs/widgets/rendering-inline-editing/
Files (wordpress-elementor-skill)
-
svn
-
references
-
branching-tagging-merging.md 12.3 KB
# Branching, Tagging, and Merging Deep reference for SVN's branch/tag/merge model. Read `svn.md` first for the mental model and the basic `svn copy` mechanics — this file covers everything past creating a simple branch or tag. ## Table of contents - Why branch at all - Creating and working with a branch - Moving a working copy between branches (svn switch) - Basic merging (sync merges) - Mergeinfo: what it is and how to read it - Reintegrating a branch back to trunk - Undoing a bad commit - Resurrecting deleted files or directories - Common branching patterns - To branch or not to branch ## Why branch at all The alternative to branching is working in isolation in a single working copy for days or weeks without committing. That's risky (one lost laptop loses everything), inflexible (hard to share work-in-progress for review), and it makes the eventual merge back much harder the longer you wait. A branch gives you a place to commit incrementally — safe, reviewable, shareable — without disturbing trunk. ## Creating and working with a branch A branch is a directory created with a server-side `svn copy`. Copying on the server is a constant-time "cheap copy" — SVN doesn't duplicate any data, it just adds a directory entry pointing at an existing tree — so branch creation is fast regardless of project size: ```bash svn copy https://example.com/repo/calc/trunk \ https://example.com/repo/calc/branches/my-feature \ -m "Creating a private branch of /calc/trunk." ``` Do this as a **remote** copy (URL to URL), not by copying a directory inside a working copy — a working-copy-side copy has to physically duplicate every file on local disk and is much slower. Check out the branch to work on it: ```bash svn checkout https://example.com/repo/calc/branches/my-feature ``` It behaves like any other working copy. Commits to it are invisible to people working on trunk (and vice versa) until someone merges. Because SVN has no native branch concept — only copies — running `svn log -v` on a file inside your branch shows its full history, including the copy event itself and everything that happened to the file on trunk before the branch point. This is normal and useful for auditing where a branch came from. ### Moving an existing working copy between branches Checking out a fresh working copy per branch isn't the only option. `svn switch` re-points a working copy you already have at a different URL, downloading only what's different: ```bash svn switch ^/calc/branches/my-feature ``` It's a superset of `svn update` — an update moves a working copy through time (same URL, newer revision); a switch moves it through time *and* space (different URL). Local edits survive the switch, which is a handy escape hatch if you've been editing trunk and realize partway through that the work belongs on a branch (or vice versa): switch, then commit. You can also switch just part of a working copy — a single subdirectory or even one file — without touching the rest. Don't confuse plain `svn switch` with `svn switch --relocate` (deprecated since 1.7 in favor of the separate `svn relocate` command): relocate is for when the repository's own URL changed — same content, new address — not for moving between branches. ## Basic merging (sync merges) **Requires Subversion 1.5+ client and server** for automatic merge tracking; strongly recommend 1.7+ on both ends (most merge-tracking bug fixes are client-side, so an old server with a new client is fine). While you work on a branch, trunk keeps moving. Periodically pull trunk's changes into your branch to avoid drifting so far apart that the eventual merge back is painful: ```bash cd my-feature-branch # must be a CLEAN working copy — svn status shows nothing pending svn merge ^/calc/trunk ``` `^/` is shorthand (1.6+) for the repository root, so you don't have to type the full URL. Output looks like: ``` --- Merging r345 through r356 into '.': U button.c U integer.c --- Recording mergeinfo for merge of r345 through r356 into '.': U . ``` The "Recording mergeinfo" lines are SVN silently updating the `svn:mergeinfo` property on the target — this is how it remembers what's already been merged so the *next* sync merge only pulls in what's new. Do not hand-edit this property. **Important 1.7+ restriction:** `svn merge` refuses to run against a mixed-revision working copy (`Cannot merge into mixed-revision working copy`). Run `svn update` first. After merging, build/test, then commit like any other change: ```bash svn commit -m "Merged latest trunk changes to my-feature." ``` ### Merging without automatic tracking (pre-1.5 servers) If your server predates 1.5, none of the above bookkeeping happens automatically. You must track merged ranges yourself, typically by noting them in commit log messages, and merge explicit revision ranges: ```bash svn merge ^/trunk -r399:HEAD ``` where 399 is the revision trunk was at when the branch was created (or the end of the last manual merge). This is painful — a strong reason to insist on 1.5+ everywhere. ## Mergeinfo: what it is and how to read it `svn:mergeinfo` is the property SVN uses to track which changesets have already been replicated into a given path. Inspect it directly: ```bash svn propget svn:mergeinfo . /trunk:341-390 ``` Or ask SVN to interpret it for you: ```bash svn mergeinfo ^/calc/trunk # revisions already merged from trunk svn mergeinfo ^/calc/trunk --show-revs eligible # revisions NOT yet merged (a preview) ``` `svn mergeinfo` needs a source URL and takes an optional target URL (defaults to the current working directory). **Mergeinfo inheritance:** a path with the property explicitly set has *explicit mergeinfo*, which its children inherit unless a child has its own explicit mergeinfo (which always wins outright — explicit mergeinfo never blends with inherited mergeinfo). A `*` suffix on a revision number in mergeinfo output (e.g. `758*`) marks it as only *partially* merged — merging it again would still produce additional changes. **Subtree merges:** merging directly into some child of a branch root (rather than the root itself) creates "subtree mergeinfo." It's handled automatically but can make `svn propget svn:mergeinfo --recursive` output large and confusing on complex repositories; use the `-v`/`--verbose` flag for a more readable breakdown. **Preview a merge without applying it:** ```bash svn merge ^/calc/trunk --dry-run ``` Shows the status codes a real merge would produce without touching the working copy. If you don't like the result of a real (non-dry-run) merge before committing, `svn revert . -R` undoes it completely and you can retry with different options — the merge isn't final until you commit. ## Reintegrating a branch back to trunk Once your feature is done: 1. Do one final sync merge (pull latest trunk into the branch), build, test, commit. 2. Get a clean, up-to-date, single-revision working copy of **trunk** (not the branch) — a fresh checkout is simplest. 3. From that trunk working copy: ```bash svn merge --reintegrate ^/calc/branches/my-feature # build, test, verify svn commit -m "Merge my-feature back into trunk!" ``` `--reintegrate` is a special-purpose flag: instead of replaying a contiguous range of revisions (like a normal sync merge), it diffs the latest trunk tree against the latest branch tree and applies exactly that difference. It's required specifically for merging a branch *back* to its parent, and it's picky: - The trunk working copy must have **no local edits and no mixed revisions** — this is enforced, not just recommended. - It only accepts a small set of other options: `--accept`, `--dry-run`, `--diff3-cmd`, `--extensions`, `--quiet`. - **A branch that has been `--reintegrate`d cannot be usefully reused** — it can't cleanly absorb further trunk changes or be reintegrated again. After a successful reintegration, either delete the branch and recreate it fresh from trunk if you need to keep working: ```bash svn delete ^/calc/branches/my-feature -m "Remove my-feature, reintegrated in r391." svn copy ^/calc/trunk ^/calc/branches/my-feature -m "Recreate my-feature from trunk@HEAD." ``` or use the "keep it alive" technique described in the SVN Book's Advanced Merging section (merge trunk into the branch once more immediately after reintegration, before continuing further branch work). **Version note:** everything above describes 1.5–1.7 behavior, where you must pass `--reintegrate` explicitly (this skill's source material is the 1.7 book). Since 1.8, `svn merge` detects a reintegrate situation automatically and the flag is deprecated — `svn merge ^/calc/branches/my-feature` (no flag) does the right thing on its own from a trunk working copy. Given how old 1.7 now is, assume auto-detection unless `svn --version` confirms an older client; passing `--reintegrate` explicitly on a modern client is harmless but unnecessary. Deleting a merged-in branch does not lose history — `svn log` on the `branches/` URL still shows it, and it can be resurrected later (see below) if needed. ## Undoing a bad commit `svn merge` can apply a changeset *backward*, which is how you roll back a specific revision without touching anything else: ```bash svn merge -c -303 ^/calc/trunk # reverse-apply r303 svn status # review svn diff # confirm the change is actually gone svn commit -m "Undoing change committed in r303." ``` `-c -303` is shorthand for `-r 303:302`. This doesn't erase r303 from history — anyone checking out an old revision between 303 and your revert commit still sees the bad change — it only removes it from `HEAD` going forward. True history deletion isn't supported by design (SVN revisions are immutable, append-only trees); the closest tool for permanently scrubbing something (e.g. an accidentally committed secret) is `svndumpfilter`, covered in `repository-admin.md`. ## Resurrecting deleted files or directories Two-step process: find the coordinate (revision + path), then copy it back. The `@807` syntax below is a *peg revision* — it tells SVN which object you mean (in case the path `real.c` refers to something different at different points in history, e.g. it was deleted and something unrelated was later added at the same path), as opposed to a plain `-r` revision, which says which point in *that* object's own history you want. For a straightforward resurrection like this they'll usually agree, but the peg is what pins down the right one if a path has a complicated past. 1. Find when it was deleted: ```bash svn log -v parent-dir/ # look for a D (deleted) entry for the path you want back ``` If it was deleted in revision 808, the last good version is at 807. 2. Bring it back — two options depending on whether you want to keep the historical link: ```bash # Preserves full history (shows as "added with history", a '+' in status): svn copy ^/calc/trunk/real.c@807 ./real.c svn commit -m "Resurrected real.c from revision 807." # Fresh start, no historical link: svn cat ^/calc/trunk/real.c@807 > ./real.c svn add real.c svn commit -m "Re-created real.c from revision 807." ``` This also works entirely server-side, without a working copy: ```bash svn copy ^/calc/trunk/real.c@807 ^/calc/trunk/ -m "Resurrect real.c from revision 807." ``` Prefer `svn copy` (not `svn merge -c -REV` reverse-applied) when only one file among several changed in the deleting revision needs to come back — reverse-merging the whole revision would also undo the other unrelated changes it contained. ## Common branching patterns - **Release branches.** Cut a branch when a version is about to ship (`branches/1.2.x`), so trunk can keep moving forward on new features while the release branch only receives stabilization fixes. Tag specific point releases (`tags/1.2.0`, `tags/1.2.1`) off the release branch as they ship. - **Feature branches.** Cut a branch for any change large or disruptive enough that committing it incrementally to trunk would break things for everyone else. Sync with trunk regularly; reintegrate when done. ## To branch or not to branch Branching has real overhead (context-switching, eventual merge effort). For small, quick changes that won't destabilize trunk, just commit to trunk directly. Reach for a branch when the change is large, long-running, experimental, or otherwise risky enough that isolating it is worth the merge-back cost later. -
properties-and-metadata.md 12.2 KB
# Properties and Metadata Deep reference for SVN's property system: versioned metadata attached to files, directories, and revisions, separate from file content itself. ## Table of contents - Property basics - Manipulating properties (the command set) - Revision properties (unversioned) - Properties and the normal workflow (status, diff, conflicts) - Automatic property setting - svn:ignore in depth - svn:externals in depth - Locking (svn lock/unlock, breaking/stealing, svn:needs-lock) - Changelists ## Property basics Properties are two-column name/value tables attached to a versioned file or directory (or, separately, to a whole revision). Names must be ASCII; values can be anything, including multi-line or binary data. Names starting with `svn:` are reserved for Subversion's own use — don't invent custom properties in that namespace. Properties are versioned exactly like file content: they're part of your local modifications until committed, they show up in `svn status`/`svn diff`, they merge and can conflict, and `svn revert` undoes property changes along with content changes. Custom properties are easy to *set* but hard to *search* — there's no indexed lookup. Finding a custom revision property means a linear walk across all revisions (`svn log --with-all-revprops --xml`); finding a custom versioned property means a recursive `svn propget` across the whole working copy. For metadata you'll need to search later, many teams instead put a structured tag in the commit log message (e.g. `Issue(s): IZ2376`) rather than a property. ## Manipulating properties ```bash svn propset copyright '(c) 2026 Example' file.php # set from a literal value svn propset license -F LICENSE.txt file.php # set from a file (multi-line/binary safe) svn propedit copyright file.php # open $EDITOR to view+edit current value svn proplist file.php # list property NAMES on a path svn proplist -v file.php # list names AND values svn propget copyright file.php # print one property's value svn propdel copyright file.php # remove a property entirely ``` Prefer `propedit` over `propset` when practical — it shows you the current value before you overwrite it, which matters most for revision properties (see below) where a mistake can't be recovered. Property commands accept multiple targets/globs at once: `svn propset copyright '...' src/*`. Note: setting a property to an empty string with `propset` does **not** delete it — `svn propdel` is the only way to actually remove one. Property names must start with a letter, `:`, or `_`; after that, digits, hyphens, and periods are also allowed. ## Revision properties (unversioned) Every revision automatically gets `svn:author`, `svn:date`, `svn:log`, etc. Unlike file/directory properties, **revision properties are not versioned** — editing or deleting one destroys the previous value with no undo. The most common legitimate use is fixing a typo in a commit message after the fact: ```bash svn propset svn:log "Corrected commit message." -r11 --revprop ``` By default, servers disable revprop editing entirely (it requires the administrator to enable it via the `pre-revprop-change` hook) — see `repository-admin.md`. ## Properties and the normal workflow `svn status` shows `M` in the *second* column (not the first) when only properties changed, not content: ``` M calc/button.c ``` `svn diff` shows property changes in a distinct, non-patch-compatible format: ``` Property changes on: calc/button.c ___________________________________________________________________ Added: copyright ## -0,0 +1 ## +(c) 2026 Example ``` The standalone `patch` program ignores this section entirely (it only understands content diffs); SVN's own `svn patch` subcommand (1.7+) does apply property changes from a diff. One property, `svn:mergeinfo`, gets special human-readable formatting in diffs specifically so merge output is legible — treat it as managed exclusively by `svn merge`, not something to hand-edit. **Property conflicts** happen just like content conflicts, reported the same way during `svn update`, and leave a `.prej` file explaining the clash. Resolve with `svn resolve --accept working` after fixing the property value by hand. ## Automatic property setting On `svn add` / `svn import`, SVN tries to help: - Sets `svn:executable` automatically if the OS execute bit is set on the file (non-Windows filesystems). - Guesses `svn:mime-type` — using a configured MIME-type-mapping file if you've set one up, otherwise heuristics (including `libmagic` where available), falling back to `application/octet-stream` if it looks non-textual. You can also configure pattern-based auto-props in the runtime config (`config` file, `[auto-props]` section) so that, say, every added `*.jpg` automatically gets `svn:mime-type = image/jpeg`, or every `*.cpp` gets `svn:eol-style = native` and `svn:keywords = Id`. That `config` file lives at `~/.subversion/config` on Unix-like systems or `%APPDATA%\Subversion\config` on Windows (created the first time the `svn` client runs). **The auto-props section is inert by default** — you also have to set `enable-auto-props = yes` in the same file's `[miscellany]` section, or none of the patterns you configure will actually fire; this is the classic reason auto-props "isn't working." This has to be configured per-client — there's no way for a server to force these defaults onto everyone connecting to it (though a `pre-commit` hook can *reject* commits that don't set expected properties). ## svn:ignore in depth Two separate mechanisms, both pattern-based (shell-style globs: `?`, `*`, `[...]`): 1. **`global-ignores`** runtime config option — whitespace-delimited patterns, applies on your machine to every working copy (e.g. editor backup files like `*~`). 2. **`svn:ignore` property** — set per-directory, versioned, so it travels with the repository and applies to everyone who checks it out. It does **not** cascade to subdirectories — set it separately wherever needed. These patterns are *appended* to the global list, not a replacement. ```bash svn propedit svn:ignore calc/ # opens editor; enter one pattern per line svn propget svn:ignore calc/ # view current patterns ``` Migrating from CVS: `svn propset svn:ignore -F .cvsignore .` imports a `.cvsignore` file directly (note: unlike CVS, SVN doesn't support `!` to reset the ignore list). Ignore patterns only affect the one-time decision of what `svn add` / `svn import` sweep in, and what `svn status` reports by default (`--no-ignore` shows ignored items with an `I` marker). Once a file is actually under version control, ignore patterns have **no further effect** — SVN will always track and commit changes to it regardless of a matching pattern. Gotcha: shell wildcards get expanded by your shell *before* SVN sees them, so `svn add *` bypasses `svn:ignore` for anything the shell's glob matches. Use `svn add --force --depth files .` for a controlled bulk-add that still honors ignore patterns. ## svn:externals in depth `svn:externals` maps a local subdirectory to a URL (optionally pinned to a revision) of another versioned directory — anyone who checks out the parent automatically gets the external pulled in too. Common use: vendored third-party code or shared assets. Modern (1.5+) syntax, one mapping per line, checkout-argument order: ``` -r148 http://svn.example.com/skinproj third-party/skins http://svn.example.com/sounds third-party/sounds ``` Relative URL forms (1.5+) avoid hardcoding the server: | Prefix | Relative to | |---|---| | `../` | the directory the property is set on | | `^/` | the repository root | | `//` | the URL scheme of the directory the property is set on | | `/` | the server root | Set/edit with `propedit` (multi-line, so avoid `propset`): ```bash svn propedit svn:externals calc/ ``` **Sharp edges worth knowing before you rely on externals:** - Pin to an explicit revision unless you deliberately want externals to float to `HEAD` — otherwise everyone's checkout silently picks up upstream changes you don't control, and backdating your working copy won't correctly backdate an unpinned external. - External working copies are genuinely separate, disjoint working copies. Committing in the parent working copy does **not** recurse into externals — you must `svn commit` inside the external directory itself. - Absolute URLs in old-style definitions break if you move/rename the directory the property lives on, or if you `svn relocate` the parent — externals don't automatically follow either. - File externals (single-file, not directory) can't be moved or deleted directly (edit the `svn:externals` property instead), and can only reference a file in the *same* repository. - `--ignore-externals` disables external processing for `checkout`, `update`, `switch`, `export`, and `status` when you need to skip them. ## Locking Locking exists for the one case copy-modify-merge handles badly: essentially-unmergeable files (binary images, fonts, compiled assets). SVN's lock is one of **three unrelated things called "lock" in SVN** — don't confuse them: 1. **Repository locks** (this section) — mutual exclusion between users. 2. **Working copy locks** — internal, prevents two SVN client processes from stepping on the same working copy; shown as `L` in `svn status`; cleared with `svn cleanup` (see `troubleshooting.md`). 3. **Database locks** — internal to the Berkeley DB backend; can "wedge" a repository if a process dies mid-transaction (see `repository-admin.md`). ```bash svn lock file.jpg -m "Editing for tomorrow's release." # claim exclusive right to commit svn status # shows K (locKed) next to the file svn info file.jpg # shows lock token, owner, comment, timestamp svn unlock file.jpg # release voluntarily ``` While locked by someone else, your commits touching that file are rejected until they unlock or you break the lock. `svn status -u` shows an `O` (locked by Other) next to affected files. **Breaking vs. stealing:** by default anyone can release anyone else's lock (not just the owner or an admin) — this is a deliberate "locks are a communication tool, not a security boundary" design choice, though `pre-lock`/`pre-unlock` hooks can enforce stricter policy if a team wants it. ```bash svn unlock --force https://example.com/repo/file.jpg # break someone else's lock svn lock --force file.jpg # steal: break + relock atomically ``` An admin can also inspect/remove locks directly on the server without a working copy: `svnadmin lslocks REPO_PATH`, `svnadmin rmlocks REPO_PATH PATH`. By default, **committing releases every lock your commit touches — even ones on files you didn't actually change** if they were part of the targets you passed to `svn commit`. Use `--no-unlock` on the commit to keep holding locks you still need. **`svn:needs-lock`** is a property, not a lock itself — attach it to a file (any value; only presence matters) and SVN makes the file read-only on checkout/update until someone locks it, then read/write while locked. It's a *reminder* mechanism (many editors refuse to save a read-only file, prompting the user to go lock it first) — it doesn't stop anyone who edits around the read-only bit, and it doesn't make the repository require a lock to accept a commit. ## Changelists A lightweight, purely local grouping mechanism — not synced to the repository — for organizing pending edits into named buckets so you can `commit`/`diff`/`revert` a subset of your working copy at once: ```bash svn changelist my-bugfix file1.php file2.php svn commit --changelist my-bugfix -m "Fix the thing" ``` Useful when you have several unrelated edits mixed together in one working copy and want to commit (or review) them separately without juggling multiple checkouts. Two limits worth knowing before relying on them: changelists only apply to files, not directories, and a file can only belong to one changelist at a time (assigning it to a new one silently moves it out of the old one). Also, **committing a file normally clears its changelist assignment** once the commit succeeds — pass `--keep-changelists` to the commit if you want the label to stick around for next time. -
repository-admin.md 8 KB
# Repository Administration Deep reference for creating, configuring, and serving SVN repositories. Most of this is server-side/admin territory rather than day-to-day client use — read `svn.md` first if you just need to *use* an existing repository. ## Table of contents - Creating a repository - Repository layout for multiple projects - Hooks - The svnadmin / svnlook toolkit - Choosing a server: svnserve vs. SSH vs. Apache - Backup and disaster recovery basics ## Creating a repository ```bash svnadmin create /var/svn/myrepo # default backend (FSFS) svnadmin create --fs-type fsfs /var/svn/myrepo # explicit FSFS svnadmin create --fs-type bdb /var/svn/myrepo # Berkeley DB backend (legacy; FSFS is standard now) ``` `svnadmin` and `svnlook` are **server-side tools** — they take a local filesystem path, not a URL, and can't operate across a network. (Passing a URL, even a `file://` one, to `svnadmin` is a very common beginner mistake.) The regular `svn` client is what takes URLs. A fresh repository starts at revision 0 (an empty root directory) with a single revision property, `svn:date`, set to creation time. Don't hand-edit anything under a repository's data store directly — `svnadmin` is the supported interface for everything short of examining hook scripts and config files, which are meant to be edited directly. ## Repository layout for multiple projects If one repository hosts several unrelated projects, give each its own project root containing its own `trunk`/`branches`/`tags`, rather than one shared set at the repository root: ``` svn list file:///var/svn/multi-project-repo project-A/ project-B/ svn list file:///var/svn/multi-project-repo/project-A trunk/ branches/ tags/ ``` If the repository holds exactly one project, the repository root can double as the project root (a bare `trunk/branches/tags` at the top). ## Hooks A hook is an executable script the repository runs automatically when a specific event happens. "Pre" hooks run before an operation and can reject it (non-zero exit); "post" hooks run after and are read-only with respect to the operation itself (useful for notifications, not enforcement). Available hooks: `start-commit`, `pre-commit`, `post-commit`, `pre-revprop-change`, `post-revprop-change`, `pre-lock`, `post-lock`, `pre-unlock`, `post-unlock`. Templates are dropped into `myrepo/hooks/` automatically at creation: ```bash ls myrepo/hooks/ post-commit.tmpl post-unlock.tmpl pre-revprop-change.tmpl post-lock.tmpl pre-commit.tmpl pre-unlock.tmpl post-revprop-change.tmpl start-commit.tmpl ``` To activate one on Unix: copy the `.tmpl` file to the same name **without** the extension, edit it, and make it executable. On Windows, the file extension itself must indicate an executable type (`.exe`, `.bat`, etc.) — Windows doesn't use the Unix executable bit. Critical gotchas: - Hooks run with an **empty environment** — no `$PATH`, nothing. Use absolute paths to every program the hook calls, and set any environment variables the hook needs explicitly. - Hooks execute as whatever OS user runs the server process (typically the service account for Apache/svnserve), so they need OS-level permission to do whatever they do, including reading the repository itself. - **Never modify a commit's contents from a hook.** It's tempting to auto-fix style/policy violations in a `pre-commit` hook, but this desyncs the client's cached view of what it just committed from what's actually in the repository, causing confusing, hard-to-diagnose problems. Validate and reject non-compliant commits instead of silently rewriting them. The most common use of `pre-revprop-change` specifically is enabling revision-property edits at all — by default, editing an unversioned revprop (e.g. fixing a typo in a commit message after the fact) is disabled server-side until this hook exists and permits it, precisely because revprop changes are not versioned and can't be undone. ## The svnadmin / svnlook toolkit Both are server-side-only (local paths, not URLs): - **`svnadmin`** — repository creation, maintenance, migration: `create`, `dump`/`load` (export/import for migration or backup), `hotcopy` (safe live backup), `verify`, `recover` (fix a wedged BDB repository), `pack` (FSFS space optimization), `setlog`/`setrevprop` (revprop edits from the server side, bypassing hooks), `lslocks`/ `rmlocks`. - **`svnlook`** — read-only inspection of a repository's current or pending (in-transaction) state: `svnlook log`, `svnlook changed`, `svnlook diff`, `svnlook tree`, `svnlook author`. Commonly called from inside hook scripts to inspect the commit that's in flight. - **`svndumpfilter`** — include/exclude specific paths when replaying a dump file; the practical way to permanently strip something sensitive out of history (SVN has no "delete this from history" command otherwise, by design — see `branching-tagging-merging.md`'s note on undoing commits). - **`svnsync`** — one-way mirroring of a repository (full or partial) to another location, revision property included. - **`svnrdump`** — dump/load over the network for a *remote* repository you don't have filesystem access to (`svnadmin dump`/`load` require local access). ## Choosing a server: svnserve vs. SSH vs. Apache No universally "best" option — trade-offs only: **Plain `svnserve`** — fastest to set up, stateful protocol (faster than WebDAV), no OS accounts needed, password never sent over the network. Downside: traffic unencrypted by default and passwords stored in clear text server-side unless you additionally configure SASL; minimal logging; no built-in web browsing of the repo. **`svnserve` over SSH** — reuses existing SSH accounts/infrastructure, still fast (stateful protocol), fully encrypted. Downside: only one auth method, minimal logging, requires shared system group or SSH key management, easy to misconfigure file permissions. **Apache + `mod_dav_svn`** — integrates with any Apache auth scheme, full Apache logging, SSL, works through corporate firewalls (it's just HTTP/S), built-in repository browsing, can be mounted as a WebDAV network drive. Downside: noticeably slower than `svnserve` (stateless protocol, more round-trips), more complex initial setup. **Rule of thumb from the SVN Book's authors:** start with plain `svnserve` for a small team getting going — least setup, fewest moving parts — and move to Apache later only if you specifically need its integration or web-browsing features. If you need to plug into existing identity infrastructure (LDAP, Active Directory, X.509), you need either Apache or `svnserve` with SASL. **Avoid exposing repositories directly via `file://` to multiple users** (or the equivalent `svn+ssh://` local-account pattern) — it removes every layer of access control between users and the raw repository data. Regardless of which you pick: run the server process as a single dedicated `svn` OS user, and make that user the sole owner of the repository directory. Deliberately out of scope here: the actual configuration syntax once you've picked one (`svnserve.conf` and `authz`/`passwd` file formats for svnserve, or the Apache `httpd.conf`/`mod_dav_svn` directives and `AuthzSVNAccessFile` setup for Apache). That's a bigger, more install-specific topic than fits a "which one and why" overview — the SVN Book's own Server Configuration chapter is the place for it if you need the exact directives. ## Backup and disaster recovery basics - **`svnadmin hotcopy SRC DST`** — safe, consistent live backup while the repository is in use. - **`svnadmin dump`** / **`svnadmin load`** — portable, human-inspectable backup/migration format; also how you migrate between FSFS and BDB backends, or between major Subversion versions when a direct upgrade isn't supported. - **`svnadmin verify`** — integrity check. - **`svnadmin recover`** — fixes a BDB repository "wedged" by a crashed process holding a database lock (see the "three meanings of lock" note in `properties-and-metadata.md`). Not applicable to FSFS, which doesn't have this failure mode. -
troubleshooting.md 8.3 KB
# Troubleshooting Deep reference for conflict resolution, mixed-revision confusion, and recovering a stuck working copy. ## Table of contents - Full conflict-resolution walkthrough - Merging conflict markers by hand - Mixed-revision working copies explained - Tree conflicts (structural conflicts) - svn cleanup and recovering from interruptions - Quick error-message lookup ## Full conflict-resolution walkthrough Conflicts surface during `svn update` (or `svn merge`, or `svn switch`) when incoming changes overlap your uncommitted local edits. SVN stops and asks interactively: ``` Conflict discovered in 'bar.c'. Select: (p) postpone, (df) diff-full, (e) edit, (mc) mine-conflict, (tc) theirs-conflict, (s) show all options: ``` Full option set (via `s`): | Key | Action | |---|---| | `e` (edit) | Open the file with conflict markers in `$EDITOR` | | `df` (diff-full) | Show all local changes plus the conflicting region | | `r` (resolved) | After manually fixing a file, tell SVN it's resolved | | `dc` (display-conflict) | Show only the conflicting regions, not the whole diff | | `mc` (mine-conflict) | Your version wins *only* where there's an actual conflict; non-conflicting incoming changes still merge in | | `tc` (theirs-conflict) | Their version wins only where conflicting; your non-conflicting changes are kept | | `mf` (mine-full) | Discard the entire incoming update for this file; keep your whole file as-is | | `tf` (theirs-full) | Discard all your local changes to this file; take the server's version entirely | | `p` (postpone) | Leave it conflicted, deal with it after the update finishes | | `l` (launch) | Open a configured external merge tool | | `s` (show all) | Print this menu | `mc`/`tc` vs `mf`/`tf` is the distinction that trips people up: the `*-conflict` pair only overrides the specifically-overlapping lines and still merges everything else cleanly; the `*-full` pair throws away one side's changes to the file **entirely**, conflicting or not. Postponing (`p`, or pre-committing to it with `svn update --non-interactive`, which auto-marks every conflict `C` without prompting) leaves three extra unversioned files next to the conflicted one: - `filename.mine` — your working copy version right before the update (omitted if SVN considers the file unmergeable/binary) - `filename.rOLDREV` — the file as it was at your last-synced (`BASE`) revision - `filename.rNEWREV` — the version just received from the server **SVN will refuse to commit a file with these leftover files present.** Resolve first: ```bash svn resolve --accept base file.c # revert to the pre-edit checked-out version svn resolve --accept mine-full file.c # keep only your version svn resolve --accept theirs-full file.c # keep only the server's version svn resolve --accept working file.c # accept whatever you hand-edited the file to (see below) ``` `svn resolve` requires explicit filenames — it won't guess which conflicted file(s) you mean. ## Merging conflict markers by hand If you edited the file directly to resolve a conflict (option `e`, or just opening it in your own editor), it'll contain markers like: ``` <<<<<<< .mine Salami Mortadella ======= Sauerkraut Grilled Chicken >>>>>>> .r2 ``` Everything between `<<<<<<< .mine` and `=======` is your version; between `=======` and `>>>>>>> .rN` is the incoming version. Edit the file down to what it *should* say, delete the marker lines entirely, save, then: ```bash svn resolve --accept working file.c svn commit -m "..." ``` Forgetting to remove the marker lines themselves is the classic mistake — SVN will happily commit a file that still contains literal `<<<<<<<` text once you've told it the conflict is resolved. ## Mixed-revision working copies explained Normal, not a bug: after a commit, only the files/directories you actually touched get bumped to the new revision number. The rest of the working copy stays at whatever revision it was already at. Run `svn status -v` to see the actual mixture — the second column is each item's individual working revision. Why this matters practically: - **A successful commit can look like a failed one.** Immediately after `svn commit` returns `Committed revision N`, the working-copy *root* is usually still at some revision **lower than N** — only the paths you touched were bumped. So `svn info --show-item revision` reads "behind" and it is easy to conclude the push didn't land. It did. Confirm against the repository, not the working copy: `svn log -l 1 <REPO_URL>` (or `svn list <REPO_URL>/tags/`), then run `svn update` to bring the whole working copy to one revision. - You **cannot** delete a file/directory that isn't fully up to date — SVN blocks it to avoid destroying changes you haven't seen yet. - You **cannot** commit a property change to an out-of-date directory, for the same reason. - As of 1.7, **`svn merge` refuses to target a mixed-revision working copy** by default (`--allow-mixed-revisions` overrides this, but only do so if you understand the consequences — merges into mixed-revision copies can produce spurious conflicts). - `svn log` on a working-copy path can show a truncated or seemingly "wrong" history if that path's local working revision is older than you expect — it reports history *as of the working revision*, not `HEAD`. Mixed revisions are also genuinely useful — deliberately backdating part of a working copy to test an older snapshot of a subdirectory, for instance — so the goal isn't to avoid them entirely, just to know a clean `svn update` at the top of the working copy is what gets you back to a single, uniform revision when an operation requires one. ## Tree conflicts (structural conflicts) Distinct from content/text conflicts: a tree conflict happens when an update or merge can't reconcile a *structural* change — e.g. someone deleted or moved a file on the server while you also had it locally modified, or renamed, or deleted. SVN can't guess intent here (was the file supposed to still exist or not?), so it flags a tree conflict rather than guessing. ```bash svn status # tree-conflicted items show a C in status too svn info file # shows a description of the specific tree conflict ``` Resolution generally means deciding by hand which side's structural intent should win (keep your local move, or accept the server's deletion, etc.), then `svn resolve` once the working copy reflects your decision. There's less blanket "accept mine/theirs" automation here than for text conflicts because the possible structural combinations vary so much — check `svn info` on the conflicted path for specifics before deciding. ## svn cleanup and recovering from interruptions If an `svn` command gets interrupted (killed, crashes, loses network mid-operation), the working copy can be left administratively locked — shown as an `L` in `svn status` (this is a *working-copy* lock, one of three unrelated things SVN calls a "lock"; see `properties-and-metadata.md` for the other two). Clear it with: ```bash svn cleanup ``` Run this from the top of the affected working copy. It also removes any leftover temporary files from the interrupted operation. If a large operation keeps getting interrupted before it can finish, SVN 1.7+ generally allows safely resuming — re-run the same command rather than starting over. ## Quick error-message lookup | Message (paraphrased) | What's actually going on | |---|---| | `Commit failed... File '...' is out of date` | Someone else committed to that file since your last update | | `svn: E155011` | The precise code behind that "out of date" message | | `Aborting commit: '...' remains in conflict` | Leftover `.mine`/`.rOLDREV`/`.rNEWREV` files — resolve before committing | | `svn: E155015` | The precise code behind that "remains in conflict" message | | `Cannot merge into mixed-revision working copy` | Run `svn update` first | | `svn: E195020` | The precise code behind that mixed-revision merge block | | `svn: warning: W160035: Path '...' is already locked by user 'X'` | Someone holds an exclusive `svn lock`; ask them or use `--force` to break/steal it | | `The subversion command line tools are no longer provided by Xcode` | macOS-specific — Apple dropped `svn` from Xcode Command Line Tools years ago; install via Homebrew (`brew install subversion`) instead | | `svn: E155036` (working copy too old) | Working copy format predates the client version; run `svn upgrade` in it |
-
-
svn.md 15.5 KB
# Subversion (SVN) Practical reference for working with Apache Subversion, distilled from *Version Control with Subversion* by Ben Collins-Sussman, Brian W. Fitzpatrick, and C. Michael Pilato (the canonical "SVN Book", licensed under CC BY 2.0 — https://creativecommons.org/licenses/by/2.0/ — source at https://svnbook.red-bean.com/) — specifically its 1.7 edition, the version linked when this skill was built. Command behavior described here is accurate for any modern client (1.14.x and similar as of this writing) unless a note says otherwise; SVN's core workflow has changed very little since 1.7, with the occasional exception called out inline (e.g. `--reintegrate` becoming automatic in 1.8+). This file covers what comes up in day-to-day use. The `references/` folder goes deeper on branching/merging, properties, repository administration, and troubleshooting — read the relevant one when the task needs it. ## Mental model Get this part right and almost everything else about SVN stops being surprising: - **Repository vs. working copy.** The repository is the single source of truth, living on a server (or in a local directory it doesn't matter which). A working copy is a local checkout you actually edit. Nothing you do locally affects anyone else until you `commit`; nothing anyone else does affects you until you `update`. Push and pull are always separate actions — committing never pulls in others' changes, and updating never publishes yours. - **Revisions are global, not per-file.** Every commit — no matter how many files it touches — creates exactly one new revision number for the *entire repository tree*. "Revision 5 of foo.c" really means "foo.c as it appears in the repository snapshot taken at revision 5"; foo.c may not have changed at all between revisions 4 and 5. This is the single biggest mental adjustment for people coming from per-file systems. - **Working copies are often mixed-revision.** After you commit, only the files you touched get bumped to the new revision number; the rest of your working copy stays where it was. This is normal, not a bug — `svn status -v` shows you the mixture. It does mean some operations (merge targets, reintegration) require an up-to-date, single-revision working copy first. - **Copy-modify-merge, not lock-modify-unlock.** By default SVN lets everyone edit their own working copy freely and reconciles changes at commit time (conflicts are rare and usually easy). True exclusive locking exists but is meant only for genuinely unmergeable files like binary images — see `references/properties-and-metadata.md`. - **Branches and tags are just directories.** SVN has no branch object. A "branch" is a directory created with a cheap, constant-time server-side `svn copy`; it's a branch only because people agree to treat it that way. A "tag" is exactly the same mechanism, used for a copy nobody is supposed to commit to. This is why the trunk/branches/tags layout convention (below) exists — SVN itself enforces none of it. ## If you know Git SVN predates Git and works differently in ways that trip people up: | Git | SVN | |---|---| | `git clone` | `svn checkout` (`svn co`) | | `git add` (stage) + `git commit` (local) | `svn add` only schedules; there's no local commit — `svn commit` goes straight to the shared repository | | `git commit` then `git push` | `svn commit` (one step, always "pushes") | | `git pull` / `git fetch` + merge | `svn update` (`svn up`) | | `git branch` (cheap, local, instant) | `svn copy trunk branches/name` (cheap, but it's a *server round-trip commit*, not local and instant) | | `git merge` | `svn merge` — but you must be in the *target* working copy and run it explicitly; there's no "current branch" | | `git log` | `svn log` (no `--oneline` by default; use `-q`) | | `git diff` (unstaged) | `svn diff` (compares working copy to the pristine checked-out copy) | | SHA hashes | Sequential global revision numbers (r1, r2, r3…) | | `.gitignore` | `svn:ignore` property (per-directory, not inherited to subdirectories) + the `global-ignores` runtime config option | | `git stash` | No real equivalent; closest is `svn diff > patch.txt` then `svn revert`, or a changelist (see `references/properties-and-metadata.md`) | | Cheap local experimentation, rewrite history freely | Nothing is ever truly deleted or rewritten; history is append-only | | Distributed, no "the" server | Centralized — one authoritative repository | The practical upshot: **commits in SVN are immediately public.** There's no staging area and no local-only commit, so don't reach for `svn commit` the way you'd reach for `git commit` — it's closer to `git push`. Use `svn status` and `svn diff` (no network needed) as your staging-area substitute to review before you commit. ## The daily work cycle This is the loop you'll use constantly. It's always some ordering of: update → change → review → fix mistakes → resolve conflicts → commit. **Get a working copy:** ```bash svn checkout https://example.com/repo/trunk my-project # full history download svn export https://example.com/repo/trunk my-project # no .svn metadata, for a clean release bundle ``` **Bring your working copy up to date:** ```bash svn update # svn up — pulls latest changes, merges into your working copy svn update -r 1729 # backdate to a specific revision (whole WC or a path) ``` **Make changes.** Just edit files normally. For structural changes, tell SVN explicitly: ```bash svn add newfile.php # schedule for addition (recursive if it's a directory) svn add --force . # bulk-add everything not ignored, without re-adding what's tracked svn delete oldfile.php # schedule for deletion (deletes locally now, removed from repo on commit) svn copy foo.php bar.php # duplicate with history retained svn move old.php new.php # rename; same as copy + delete svn mkdir newdir # create + schedule for addition ``` None of these touch the repository until you commit. **Review before committing** (all fully offline — no network needed): ```bash svn status # svn st — overview: ? untracked, A added, D deleted, M modified, C conflicted svn status -v # long form — also shows working revision per item (reveals mixed revisions) svn status -u # -u contacts the repo, flags items that are out of date (asterisk marker) svn diff # line-level unified diff of local changes svn diff -r 3 # diff working copy against a specific revision svn diff -r 2:3 # diff two repository revisions directly ``` **Fix mistakes:** ```bash svn revert file.php # discard local changes, restore pristine version svn revert -R . # recursive revert of an entire directory tree ``` `svn revert` undoes *any* scheduled operation — an accidental `add`, `delete`, or edit — not just content changes. **Resolve conflicts.** You'll see this during `svn update` when someone else's changes overlap yours: ``` Conflict discovered in 'bar.c'. Select: (p) postpone, (df) diff-full, (e) edit, (mc) mine-conflict, (tc) theirs-conflict, (s) show all options: ``` Fastest paths: `p` to postpone and deal with it after the update finishes, or pick a whole-file winner immediately with `svn resolve --accept mine-full file` / `--accept theirs-full file`. For everything in between (editing conflict markers by hand, partial accepts, non-interactive updates), see `references/troubleshooting.md`. **Commit:** ```bash svn commit -m "Fix login redirect bug" # svn ci svn commit -F commit-message.txt # message from a file ``` If a commit is rejected as "out of date," someone beat you to it — run `svn update`, resolve anything that conflicts, and commit again. SVN will never silently overwrite someone else's work. ## Examining history ```bash svn log # reverse-chronological log for the current path svn log -r 5:19 # revisions 5 through 19, chronological svn log -r 19:5 # same range, reverse order svn log -v # -v also lists every changed path per revision svn log --diff # append a unified diff to each log entry (1.7+) svn info # URL, revision, last-changed author/date for a path -- the quickest single-item lookup svn cat -r 12 file.php # print a file as it existed at revision 12 svn cat -r 12 file.php > old.php # ...and save it svn blame file.php # a.k.a. svn annotate / svn praise — line-by-line last-changed attribution svn list https://example.com/repo/trunk # directory listing without checking anything out ``` Gotcha: `svn log` with no arguments right after a commit often *won't* show the commit you just made — the parent directory's working revision usually lags behind the file you touched. Run `svn update` first, or pass `-r` explicitly. ## Repository layout: trunk / branches / tags SVN imposes no structure — this convention is universal purely because practically every project and every SVN-based platform (including the WordPress.org plugin and theme directories) expects it: ``` project-root/ ├── trunk/ # the main line of development ├── branches/ # divergent lines of development └── tags/ # named, frozen snapshots — nobody commits here ``` Getting an existing unversioned tree into a fresh repository: ```bash svn import /path/to/local/project https://example.com/repo/project/trunk -m "Initial import" ``` Note `svn import` commits directly — it does **not** turn your local folder into a working copy afterward. Check out a fresh working copy separately if you want to keep working on it. **Creating a branch or tag is the same operation** — a server-side `svn copy`, which is a cheap, near-instant commit that doesn't duplicate any data: ```bash svn copy https://example.com/repo/trunk https://example.com/repo/branches/my-feature \ -m "Start my-feature branch" svn copy https://example.com/repo/trunk https://example.com/repo/tags/1.2.0 \ -m "Tag release 1.2.0" ``` The only difference between a branch and a tag is social convention: a tag is a copy everyone agrees not to commit to. If someone does commit to it, it has effectively become a branch. Full depth on branching, syncing, and merging is in `references/branching-tagging-merging.md` — read it before doing anything beyond a simple tag, especially before a `--reintegrate` merge. ## Properties, in brief Properties are versioned metadata (name/value pairs) attached to files, directories, or revisions — separate from file content. The ones worth knowing immediately: - `svn:ignore` — per-directory list of filename patterns to hide from `svn status` / skip on `svn add --force`. Does **not** cascade to subdirectories (unlike `.gitignore`). Set it with `svn propedit svn:ignore path/` (multi-line values need an editor, not `propset`). - `svn:mime-type` / `svn:executable` / `svn:eol-style` — file portability properties; auto-detected on `add`/`import` but worth checking on binary assets and shell scripts. - `svn:externals` — pulls another repository path into a subdirectory automatically for everyone who checks out. Useful for vendored dependencies, with real sharp edges (see reference file). - `svn:needs-lock` — makes SVN mark a file read-only until someone runs `svn lock` on it; the practical way to signal "this file can't be merged, please take turns" for binary assets. Full command syntax (`propset`, `propedit`, `propget`, `proplist`, `propdel`, revision properties) and gotchas are in `references/properties-and-metadata.md`. ## Repository administration, in brief ```bash svnadmin create /var/svn/myrepo # create a repository (FSFS backend by default) ``` Hooks (`pre-commit`, `post-commit`, `start-commit`, `pre-revprop-change`, etc.) live as executable scripts in `myrepo/hooks/` — templates are dropped there automatically at creation. Choosing between `svnserve`, `svnserve` over SSH, and Apache/mod_dav_svn, plus `svnadmin`/`svnlook` toolkit basics and backup, are in `references/repository-admin.md`. ## Common errors and what they mean | Message | Meaning | Fix | |---|---|---| | `Commit failed... File '...' is out of date` | Someone else committed since your last update | `svn update`, resolve, retry commit | | `E155011` | The exact code behind the "out of date" message above | Same fix — update, resolve, retry | | `Aborting commit: '...' remains in conflict` | A conflicted file has leftover `.mine`/`.rOLDREV`/`.rNEWREV` files | Resolve the conflict with `svn resolve`, then commit | | `E155015` | The exact code behind the "remains in conflict" message above | Same fix — resolve, then commit | | `Cannot merge into mixed-revision working copy` | Your working copy has multiple revisions in it | `svn update` first, then merge | | `svn: warning: W160035: Path '...' is already locked by user 'X'` | Someone holds an exclusive lock | Ask them, or `svn unlock --force` / `svn lock --force` to break/steal it (see properties reference) | More detail, plus mixed-revision quirks and `svn cleanup`, is in `references/troubleshooting.md`. ## Worked example: publishing to a WordPress.org-style plugin/theme SVN WordPress.org's plugin and theme directories run on exactly the trunk/branches/tags/assets convention described above, so the general workflow applies directly: ```bash svn co https://plugins.svn.wordpress.org/your-plugin-slug cd your-plugin-slug # copy release-ready files into trunk/ (make sure vendored/composer # dependencies are included -- there's no build step run on the server) svn add trunk/* svn commit -m "Initial release" # tag the release so it's what actually gets served to users svn copy trunk tags/1.0.0 svn commit -m "Tag 1.0.0" ``` Two platform-specific gotchas that aren't in the SVN Book itself but matter a lot here: the `Stable tag` field in `readme.txt` must exactly match the tag folder you create, or the directory serves the wrong version; and the `assets/` folder (screenshots, icons, banners) sits as a sibling of `trunk` and `tags` at the repository root, not inside either of them. ## Reference files - `references/branching-tagging-merging.md` — creating and syncing branches, moving a working copy between branches with `svn switch`, `svn merge` mechanics, `--reintegrate`, mergeinfo, cherry-picking, undoing a bad commit, resurrecting deleted files, common branching patterns (release branches, feature branches), when *not* to branch. - `references/properties-and-metadata.md` — full property command syntax, automatic property setting, `svn:ignore` in depth, `svn:externals` formats and pitfalls, the locking feature end to end (`svn lock`/`unlock`, breaking/stealing locks, `svn:needs-lock`), changelists. - `references/repository-admin.md` — creating and administering repositories, writing hooks, the `svnadmin`/`svnlook` toolkit, choosing `svnserve` vs. `svnserve`-over-SSH vs. Apache/`mod_dav_svn`, backup and replication basics. (Choosing a server is covered; writing that server's detailed config files is explicitly out of scope — see the note inside.) - `references/troubleshooting.md` — full conflict-resolution walkthrough (interactive options, merging conflict markers by hand, non-interactive mode), mixed-revision working copies explained, tree conflicts, `svn cleanup` and recovering from interruptions, the three different meanings of "lock" in SVN. Read the relevant reference file in full before attempting anything beyond the basics covered above — each goes considerably deeper than this overview and includes the exact command sequences and warnings from the source material.
-
-
debugging.md 19.3 KB
# Debugging — WordPress & Elementor > **When to read this file:** Diagnosing a bug, a white screen, a failed wp.org check, a CSS/JS > change that "didn't come through," or an editor preview that broke. Work the layers in order: > **static analysis first** (cheapest — catches it before it runs), then **runtime logging**, > then **Elementor-specific** state, then **browser** (JS/CSS). The symptom→cause table at the > bottom maps real failures to where to look. > ⚠️ **`php -l` / `node --check` prove SYNTAX ONLY.** They do **not** catch a typed-signature > mismatch (a fatal — see `field-notes.md` §1), a missing/ineffective control, a leaked filter, > escaping issues, or any runtime behaviour. Passing lint means nothing about correctness. --- ## 1. Static analysis (catch it before it runs) Three complementary tools — each catches a different class of bug: | Tool | Catches | Does NOT catch | |---|---|---| | **PHPCS + WPCS** | Coding standards, **security** (`WordPress.Security.*`: unescaped output, unsanitized input, missing nonces), naming/prefix, i18n | Type errors, logic bugs, runtime behaviour | | **PHPStan** (WordPress) | **Type & logic** bugs — wrong arg types, impossible conditions, dead code, null misuse, undefined methods | Coding-style / escaping policy | | **Plugin Check 2.1.0** | The **review-grade superset** — bundles PHPCS+WPCS plus repo/guideline/perf/a11y/i18n checks (what wp.org reviewers run); 2.1.0 adds an SVN-repo scan, a PHP-error-reporting check, and per-dependency `Requires Plugins` validation | Guarantees nothing about approval (human review is still mandatory) | ### PHPCS + WPCS — WordPress Coding Standards PHPCS is the engine; **WPCS** (WordPress Coding Standards) is the ruleset that emits the `WordPress.Security.*`, `WordPress.NamingConventions.*`, `WordPress.WP.I18n`, etc. codes. ```bash # Install per-project (WPCS 3.x pulls PHPCSUtils + PHPCSExtra automatically; # the composer-installer auto-registers installed_paths so phpcs finds WPCS): composer require --dev \ wp-coding-standards/wpcs:"^3.1" \ dealerdirect/phpcodesniffer-composer-installer:"^1.0" # Run (uses phpcs.xml.dist below); phpcbf auto-fixes what is mechanically fixable: ./vendor/bin/phpcs ./vendor/bin/phpcbf ``` Drive it with a committed **`phpcs.xml.dist`** ruleset — this is where you set your text domain, prefixes, and minimum WP version so the security/naming/i18n sniffs actually apply: ```xml <?xml version="1.0"?> <ruleset name="MyPlugin"> <description>WPCS ruleset for my plugin.</description> <file>.</file> <exclude-pattern>*/vendor/*</exclude-pattern> <exclude-pattern>*/node_modules/*</exclude-pattern> <exclude-pattern>*/includes/lib/*</exclude-pattern> <!-- vendored libs: don't lint, disclose --> <arg value="ps"/> <!-- p = progress, s = show sniff codes --> <arg name="extensions" value="php"/> <arg name="parallel" value="8"/> <rule ref="WordPress"/> <!-- WordPress-Core + Extra + Docs + security --> <config name="minimum_wp_version" value="7.0"/> <!-- i18n: every text-domain must match the slug (else WordPress.WP.I18n flags it) --> <rule ref="WordPress.WP.I18n"> <properties> <property name="text_domain" type="array"> <element value="myplugin"/> </property> </properties> </rule> <!-- Prefix sniff: declare your prefixes or every global symbol is flagged --> <rule ref="WordPress.NamingConventions.PrefixAllGlobals"> <properties> <property name="prefixes" type="array"> <element value="myplugin"/> <element value="MyPlugin"/> <element value="MYPLUGIN"/> </property> </properties> </rule> </ruleset> ``` > ⚠️ **PHPCS `--standard=WordPress` surfaces MORE than Plugin Check does.** PC runs a curated > **review** ruleset; the full `WordPress` standard adds `WordPress-Extra` style nags PC's review > ruleset doesn't enforce. So: use **Plugin Check** to answer *"will this pass review?"*, and > **PHPCS/WPCS** for general code-quality and the fast, file-level security sniffs. WPCS is also > **bundled inside Plugin Check** (`plugin-check/vendor/…`) — that bundled copy is what emits PC's > `WordPress.Security.*` codes. To run phpcs against just the security sniffs quickly: > `phpcs --standard=WordPress --sniffs=WordPress.Security.EscapeOutput,WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput path/`. ### PHPStan — type & logic bugs PHPCS can't see ```bash composer require --dev phpstan/phpstan szepeviktor/phpstan-wordpress ``` ```neon # phpstan.neon includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon parameters: level: 5 # 5 is a sane WordPress baseline; raise gradually paths: - includes # For Elementor/WooCommerce types, add their stubs so PHPStan stops reporting # "unknown class \Elementor\Widget_Base": # scanFiles: # - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php ``` ```bash ./vendor/bin/phpstan analyse # Lock in unavoidable false positives (e.g. dynamic Elementor internals) as a baseline: ./vendor/bin/phpstan analyse --generate-baseline ``` PHPStan catches things lint and PHPCS miss — e.g. an **untyped override of a typed Elementor method** (the white-screen fatal), a dead `is_string()` guard after `sanitize_text_field()` (`function.alreadyNarrowedType`), or a wrong return type. **Pair it with a typed `\Elementor\Widget_Base` stub** that instantiates the widget so signature mismatches fatal at class-declaration time during testing, not on the live site (see `field-notes.md` §1, §11). ### Plugin Check 2.1.0 — the reviewer's tool Full categories and usage are in **`wp-org-guidelines.md`**. For debugging: ```bash wp plugin check <your-plugin-slug> --format=json --exclude-directories=includes/lib ``` Static checks run by default; **runtime** checks need `--require .../cli.php`. Run it until your own code is **0 findings** before every submission *and* every update (PC auto-scans updates since Oct 2025). JSON output is grouped under `FILE:` headers — parse JSON, not CSV (commas in messages break naive splitting). ### `$wpdb` sniffs & suppression mechanics — from real review rounds Every item below cost a review round on a live wp.org submission: - **Single-line `// phpcs:ignore` is unreliable on DB code — use the block form.** For any `$wpdb->…` call, wrap with `// phpcs:disable RuleA, RuleB` … `// phpcs:enable RuleA, RuleB`. Single-line ignores repeatedly failed to suppress `DirectDatabaseQuery.*` on `get_col()`/`DESCRIBE` lines; the block form always works. - **`$wpdb->prepare( $built_sql, $bind_array )` still trips `WordPress.DB.PreparedSQL.NotPrepared`** when the first argument is a *built variable* rather than a string literal — even though the call is correctly prepared. No dynamic query can satisfy the sniff: include `PreparedSQL.NotPrepared` in the disable block with a **true** justification ("identifiers are schema-whitelisted, values are bound"). - **A valid `prepare()` needs at least one placeholder.** `prepare( $sql )` with no `%s`/`%d` is the "missing argument 2 for wpdb::prepare" anti-pattern — guarantee one (e.g. an always-present `LIMIT %d`). - **List EVERY sniff that fires, not just the obvious one.** A single direct `$wpdb` call can trip `DirectDatabaseQuery.DirectQuery`, `.NoCaching`, `.SchemaChange` (any DDL), `PreparedSQL.InterpolatedNotPrepared` **and** `PluginCheck.Security.DirectDB.UnescapedDBParameter` — each must be named in the disable block or it still reports. - **Exception messages are "output".** `throw new RuntimeException( "… $var" )` flags `EscapeOutput.ExceptionNotEscaped` — wrap the message (or its interpolated parts) in `esc_html()`. - **Schema and index questions are a different file.** These sniffs are about *suppression*; for the `CREATE TABLE` / `dbDelta()` rules, the `varchar(191)` index limit, `EXPLAIN` output and MariaDB↔MySQL portability, see **`mariadb.md`**. - **Core-private functions are forbidden.** E.g. `wp_get_sidebars_widgets()` is `@access private` — use `apply_filters( 'sidebars_widgets', get_option( 'sidebars_widgets', [] ) )` instead (and the re-applied CORE filter then needs a justified `NonPrefixedHooknameFound` ignore). - **`Requires at least` is a hard compatibility gate.** Plugin Check's `wp_function_not_compatible_with_requires_wp` is an ERROR keyed on the header — `function_exists()` guards do **NOT** satisfy it. If you call `str_starts_with()`/`str_contains()` (WP 5.9+ polyfills) or `wp_register_ability()` (WP 6.9+), set the header to the real floor. - **`Tested up to` going stale is an ERROR, and it costs you visibility.** Plugin Check raises `outdated_tested_upto_header` the moment a new WP major ships, and states the consequence plainly: the plugin *"will not show up in searches"* until the header matches. It is not a warning to defer — bump it as part of every release, and only after actually exercising the plugin on that version, which a throwaway local install of `latest.zip` makes cheap. Note that your dependencies lag independently: Elementor's own header sat at 7.0.4 days after WP 7.1 shipped, which is normal and not a reason to hold your own back. --- ## 2. Runtime WordPress debugging Turn on debugging in **`wp-config.php`** (a dev/staging site — never `WP_DEBUG_DISPLAY` on prod): ```php define( 'WP_DEBUG', true ); // enable error reporting define( 'WP_DEBUG_LOG', true ); // write to wp-content/debug.log (or a path string) define( 'WP_DEBUG_DISPLAY', false ); // keep errors OUT of the page; read the log instead define( 'SCRIPT_DEBUG', true ); // load unminified core/Elementor CSS & JS define( 'SAVEQUERIES', true ); // record DB queries (Query Monitor / debugging only) @ini_set( 'display_errors', 0 ); ``` - **`wp-content/debug.log`** is the first place to look for a white screen or a 500 — the fatal's message, file, and line are logged there. - **Query Monitor** (free plugin) is the single most useful runtime tool: PHP errors, slow/duplicate queries, hooks fired, enqueued scripts/styles, REST calls, and which template/callback ran. - **Your own logging**, guarded so it never leaks on production: ```php if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'myplugin: ' . wp_json_encode( $debug_data ) ); } ``` (Raw error text can contain API keys / user data — never log unguarded. See `php-standards.md`.) **White-screen-of-death (WSOD):** it's a PHP **fatal**. Enable `WP_DEBUG_LOG`, reload, read the last lines of `debug.log` — you get the exact file:line. The classic Elementor cause is an **untyped override** of `has_widget_inner_wrapper()` / `is_dynamic_content()` / `get_categories()` (runs on `wp_enqueue_scripts`, so it kills *every* page). `php -l` cannot catch it — PHPStan + a typed stub can. --- ## 3. Elementor-specific debugging - **Safe Mode** (Elementor → Tools → General → *Enable Safe Mode*) loads the editor with only Elementor active — isolates whether a bug is yours or a theme/third-party conflict. - **Regenerate Files & Data** (Elementor → Tools → General) — **the fix when a CSS change "didn't come through."** Elementor caches **per-page CSS**; new `--var` defaults or selector changes won't appear until you regenerate + hard-refresh. Tell the user this instead of assuming it's broken. - **System Info** (Elementor → System Info) — versions, server config, active experiments; paste it when diagnosing compatibility. - **Element cache freezes** = the "An error occurred" form / dead-redirect class of bug: a widget without `is_dynamic_content(): true` gets its HTML cached, freezing per-request nonces and `?redirect_to=`. Symptom looks like a logic bug; fix is the cache flag + clear Elementor cache. - **Editor preview blank / crashing** = a JS error in `content_template()` — a leaked `add_filter()` from `render()` (it re-runs on every control change), an "undefined index," or a triple-braced value. Open the **browser console while in the editor**; check that `content_template()` mirrors `render()`. - **`?elementor-preview` / Safe Mode** in the URL force-load the editor preview context — useful for reproducing editor-only behaviour from your URL-rewrite/context-detection code. --- ## 4. Frontend / JS / CSS debugging - **Browser console** — JS errors, and your own `console.error()` from AJAX handlers. An Elementor JS handler that "never runs" on an AJAX-loaded widget (popup, loop item) is almost always bound with `window.addEventListener('elementor/frontend/init', …)` (silently missed on Elementor < 3.5) or shipped with `strategy:'defer'` — use `jQuery(window).on(...)` and no defer (see `js-css-standards.md`). - **Network tab** — failed REST/AJAX calls, 403 from a stale/expired nonce (refresh-and-retry pattern in `rest-api.md`), wrong response shape. - **Inspect *computed* styles, not the stylesheet** — this is the only way to catch the bugs that "look fine" in code: a `var(--x)` that resolved to **nothing** (custom properties cascade parent→child only — see `field-notes.md` §4), a control selector that **lost on specificity** to theme/Elementor CSS, or a flex child overflowing because of a `min-width` floor. Screenshots alone miss all of these. - **`SCRIPT_DEBUG`** serves Elementor's unminified frontend JS so stack traces are readable. --- ## 5. Symptom → likely cause → where to look | Symptom | Likely cause | First check | |---|---|---| | **White screen on every page** | PHP fatal — often an untyped override of a typed Elementor method, or a parse error in an included file | `wp-content/debug.log` (exact file:line); then PHPStan + typed stub | | **Form shows "An error occurred" / redirect is dead** | Elementor element-cache froze a per-request nonce / `redirect_to` | Set `is_dynamic_content(): true`; clear Elementor cache | | **CSS change "didn't come through"** | Per-page CSS cache, or a `var()` resolving to nothing | **Regenerate Files & Data** + hard refresh; inspect **computed** styles for the empty var | | **Editor preview blank / broken** | `content_template()` JS error (triple-brace, leaked filter, undefined index) | Browser console **in the editor**; diff `content_template()` vs `render()` | | **Widget renders as "widget not found" after an update** | `get_name()` was changed (stored in `_elementor_data`) | Restore the old name, or migrate the stored JSON tokens (`field-notes.md` §1) | | **JS handler never fires on a popup / loop item** | Bound init with `addEventListener` (not jQuery) or used `strategy:defer` | `jQuery(window).on('elementor/frontend/init', …)`, no defer | | **Giant logo / broken layout when embedding an app** | Theme/Elementor CSS out-specifies single-class selectors | Boost base specificity (doubled class); inspect computed styles (`field-notes.md` §5) | | **wp.org review / Plugin Check flags `EscapeOutput`** | `echo $var` / `echo $this->method()` not escaped at output | Escape at the point of output; make methods echo literals + `esc_*` (`field-notes.md` §3) | | **Slow admin/front page** | N+1 queries / unbounded `WP_Query` | Query Monitor → Queries by component; add `no_found_rows`, scope `post_type` (`performance.md`) | | **Translation "doesn't load" though the `.mo` is right there** | The JIT loader only scans `WP_LANG_DIR/plugins/`, never the plugin's own `/languages/` | Put the compiled file in `wp-content/languages/plugins/{slug}-{locale}.mo` (`wordpress-apis.md` §6) | | **One style control does nothing, on one widget only** | Your own CSS neutralises that property with `!important`, outranking Elementor's generated rule | List the rules matching the element in a live DOM; drop the `!important` and win by specificity instead (`field-notes.md` §4) | | **A specific style control is overridden by a more generic one** | Both selectors match the element at equal specificity; the group registered LATER wins on source order | Scope the specific control one level deeper (`{{WRAPPER}} .panel .item`) (`field-notes.md` §4) | | **A filter/override applies to some strings on a page but not others** | The stubborn ones were baked into a config array at registration (`init`), before anything could hook the filter | Defer the value to a closure resolved at render time (`field-notes.md` §6) | --- ## 5b. Testing internationalization without fooling yourself i18n gives false negatives more readily than almost anything else — three separate traps produced "it's broken" results in one session when the code was fine: - **Switching locale mid-request proves nothing.** Adding a `locale` filter (or `switch_to_locale()`) *after* WordPress has bootstrapped leaves everything already registered in the old locale. Field labels, post-type labels and control labels are translated **once, on `init`** — flip the locale afterwards and those keep their English text while later `__()` calls come out translated. You then "discover" a bug that does not exist on a real request. **Do it properly:** make the site itself the target locale before bootstrapping — `wp language core install ka_GE` then set the `WPLANG` option — and run with no locale filters at all. Restore it afterwards. - **`switch_to_locale()` silently refuses** if that locale's core translation set isn't installed; `get_locale()` still returns `en_US` and every assertion "fails". Print `determine_locale()` before trusting any i18n test. - **WP-CLI caches the text domain during bootstrap.** By the time your `eval` runs, `$l10n[$domain]` is already populated (often as `NOOP_Translations`) and the just-in-time loader will not re-run. Call `unload_textdomain( $domain, true )` first, or the test reports English no matter what. Verify a catalogue mechanically before shipping it: assert that the printf placeholders (`%s`, `%d`, `%1$s`) and HTML tags in each `msgstr` match its `msgid`. A dropped or renamed placeholder is a runtime break that every PHP lint and unit test will pass. --- ## 6. Verification ≠ debugging A green lint/test run is not proof of correctness — see `field-notes.md` §11. Two reminders that prevent wasted hours: - **You usually can't see visual bugs from static checks.** Verify on a **live render** (the WP MCP bridge / a browser-driven preview), inspecting computed styles — not screenshots alone. - **When a check fails, suspect the check before the artifact.** Mis-scoped regex or byte-slicing multibyte UTF-8 (`head -c`/`tail -c` cutting a character mid-byte) produces confident false alarms. Verify the harness (paths, encodings, regex), then the file. ### Not every security-scanner finding is a bug (OWASP ZAP triage) Triage scanner output into four buckets before "fixing" anything — all four from one real ZAP run: | Bucket | Real example | Action | |---|---|---| | **Real** | `X-Content-Type-Options: nosniff` missing | Fix — add to ALL plugin responses | | **False positive** | "Debug Error Message" matched the literal words "PHP error log" in a tool *description* string | Document it — scanners substring-match benign doc text | | **Intentional** | `Access-Control-Allow-Origin: *` on public OAuth discovery docs only (no secrets; the sensitive endpoint keeps scoped CORS) | Keep the split; explain why | | **Environmental** | `X-Powered-By: PHP/8.x` (server `expose_php`), spec-required timestamps | Server config / protocol-required — not the plugin's bug | -
elementor-extending.md 18.9 KB
# Elementor — Extension Points Beyond Widgets > **When to read this file:** Extending Elementor *past* the basics in `elementor-patterns.md` > (widgets, dynamic tags, loop queries, form actions, theme conditions). This covers the rest of > the documented **V3 extension surface**: custom **form fields**, **theme locations**, injecting > controls into **native** widgets, **Finder** and **context-menu** items, plus a consolidated > **hooks** reference and the **deprecations** to avoid. > > Canonical source — verify signatures here, the API evolves: developers.elementor.com/docs/ > > ℹ️ **V4 / Atomic note (verified mid-2026):** everything below is the stable **V3** extension API > (the skill's target). There is **still no published third-party PHP API for building custom > Atomic Elements** — no `Atomic_Widget_Base`; the official docs still point to `Widget_Base` for > custom widgets. Atomic Elements are documented only as a *data structure* > (developers.elementor.com/docs/data-structure/atomic-elements/), not a creation API. **Keep > building with V3 `Widget_Base`** — see SKILL.md §1. (Newer 4.x **control types** like > `VISUAL_CHOICE` are usable from V3 widgets today — see §8 below.) --- ## 1. Custom Form Field (Elementor Pro) The sibling of a Form **Action** (`elementor-patterns.md`). A custom **field** adds a new input type to the Form widget. Extends `\ElementorPro\Modules\Forms\Fields\Field_Base`. ```php // Requires: Elementor Pro class MyPlugin_Range_Field extends \ElementorPro\Modules\Forms\Fields\Field_Base { public function get_type(): string { return 'myplugin_range'; } public function get_name(): string { return esc_html__( 'Range Slider', 'myplugin' ); } // ✅ Pro 3.28+: declare field assets via METHODS (the old $depended_scripts / // $depended_styles properties on Field_Base are deprecated). public function get_script_depends(): array { return [ 'myplugin-range-field' ]; } // $item = this field's settings; $form = the Form widget instance. public function render( $item, $item_index, $form ): void { $form->add_render_attribute( 'input' . $item_index, [ 'type' => 'range', 'min' => $item['min'] ?? 0, 'max' => $item['max'] ?? 100, 'class' => 'elementor-field-textual myplugin-range', ] ); // get_render_attribute_string() returns pre-built, safe attribute markup. echo '<input ' . $form->get_render_attribute_string( 'input' . $item_index ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Elementor-built attribute string } // Optional: add per-field settings controls to the Form's field repeater. public function update_controls( $widget ): void { $elementor = \ElementorPro\Plugin::elementor(); $control = $elementor->controls_manager->get_control_from_stack( $widget->get_unique_name(), 'form_fields' ); if ( is_wp_error( $control ) ) { return; } $field_controls = [ /* … add 'min'/'max' controls with 'condition' => ['field_type'=>$this->get_type()] … */ ]; $control['fields'] = \ElementorPro\Core\Utils::array_inject( $control['fields'], 'placeholder', $field_controls ); $widget->update_control( 'form_fields', $control ); } } // ✅ Register on the Pro fields registrar (parallels elementor_pro/forms/actions/register). add_action( 'elementor_pro/forms/fields/register', function( $fields_registrar ) { require_once MYPLUGIN_PATH . 'includes/class-myplugin-range-field.php'; $fields_registrar->register( new MyPlugin_Range_Field() ); } ); ``` **Validation / sanitization** of submitted values happens on the form lifecycle hooks, not the field class: ```php add_action( 'elementor_pro/forms/validation', function( $record, $ajax_handler ) { foreach ( $record->get_field( [ 'type' => 'myplugin_range' ] ) as $id => $field ) { if ( ! is_numeric( $field['value'] ) ) { $ajax_handler->add_error( $id, esc_html__( 'Invalid value.', 'myplugin' ) ); } } }, 10, 2 ); ``` Other useful form hooks: `elementor_pro/forms/process` (after validation), and `elementor_pro/forms/new_record` (act on a successful submission). --- ## 2. Theme Locations (Elementor Pro Theme Builder) The sibling of a Theme **Condition**. A **location** is a slot a Theme Builder template can fill (your theme/plugin defines `header`, `footer`, `single`, or a custom one). ```php // Requires: Elementor Pro. Register the location. add_action( 'elementor/theme/register_locations', function( $manager ) { $manager->register_location( 'myplugin_before_content', [ 'label' => esc_html__( 'Before Content', 'myplugin' ), 'multiple' => false, // true = allow stacking multiple templates 'edit_in_content' => true, // edit inline in the content area ] ); } ); ``` ```php // In the theme/plugin template, output the location with a graceful fallback: if ( function_exists( 'elementor_theme_do_location' ) ) { if ( ! elementor_theme_do_location( 'myplugin_before_content' ) ) { // No template assigned → render default markup here. } } ``` Themes that want Elementor to fully manage header/footer declare support: `add_theme_support( 'elementor' );` and register their locations the same way. --- ## 3. Add controls to — or filter the output of — a NATIVE widget You don't always build a new widget; often you just want to extend an existing one. **Inject a control into a native widget's existing section** (no subclassing): ```php // Hook shape: elementor/element/{element_name}/{section_id}/before_section_end (or after_section_start) add_action( 'elementor/element/heading/section_title/before_section_end', function( $element, $args ) { $element->add_control( 'myplugin_badge', [ 'label' => esc_html__( 'Badge Text', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, ] ); }, 10, 2 ); ``` Find the `{element_name}` and `{section_id}` by inspecting the native widget's `register_controls()` (e.g. `heading` / `section_title`, `button` / `section_button`). **Filter a native widget's rendered HTML:** ```php add_filter( 'elementor/widget/render_content', function( string $content, $widget ): string { if ( 'heading' !== $widget->get_name() ) { return $content; } $badge = $widget->get_settings_for_display( 'myplugin_badge' ); if ( $badge ) { $content .= '<span class="myplugin-badge">' . esc_html( $badge ) . '</span>'; } return $content; }, 10, 2 ); ``` --- ## 4. Finder & Context Menu (editor UX — niche) **Finder** (Ctrl/Cmd+E quick search) — add your plugin's admin destinations: ```php add_action( 'elementor/finder/register', function( $categories_manager ) { $categories_manager->register( new MyPlugin_Finder_Category() ); } ); class MyPlugin_Finder_Category extends \Elementor\Core\Common\Modules\Finder\Base_Category { public function get_title(): string { return esc_html__( 'My Plugin', 'myplugin' ); } public function get_category_items( array $options = [] ): array { return [ [ 'title' => esc_html__( 'Settings', 'myplugin' ), 'icon' => 'settings', 'url' => admin_url( 'admin.php?page=myplugin' ), 'keywords' => [ 'myplugin', 'settings', 'options' ], ], ]; } } ``` > ⚠️ The Finder registrar hook/signature has shifted across versions (older builds used the > `elementor/finder/categories` filter). Verify against current docs before shipping. **Context Menu** (editor right-click) is **JS-side**, via the editor hooks: ```js elementor.hooks.addFilter( 'elements/widget/contextMenuGroups', ( groups, view ) => { groups.push( { name: 'myplugin', actions: [ { name: 'my_action', title: 'My Action', callback: () => { /* … */ } } ], } ); return groups; } ); ``` --- ## 5. Add a TAB to the editor's Elements panel (Widgets / Components / Globals / **yours**) The panel tab an SEO or content plugin wants: a first-class entry beside **Widgets**, **Components** and **Globals**, rendering your own UI inside the editor. Elementor **4.x ships an official API for this** — use it; do not scrape the panel markup (see the warning below). ```js // Elementor 4.x — the supported way. Registers the tab, its panel route AND the nav button. window.elementorV2.editorElementsPanel.injectTab( { id: 'myplugin', // becomes the route panel/elements/myplugin label: 'My Plugin', // nav button text component: MyPanelComponent, // a REACT component (window.React is global in the editor) position: 3, // optional index in the nav; omit to append } ); ``` `injectTab()` internally does all three things the old manual recipe needed — the `panel/elements/regionViews` filter (with an empty legacy Marionette view as a placeholder), `$e.components.get( 'panel/elements' ).addTab()`, and building the nav `<button>` — then renders your component through a React **Portal** into `#elementor-panel-elements-wrapper`. > **Verify against the shipped source, not memory.** This API is exported from Elementor's own > package — read > `elementor/assets/js/packages/editor-elements-panel/editor-elements-panel.js` in the installed > plugin (it is unminified) if behaviour ever changes. Confirmed present in **4.2.0**; the script > handle is **`elementor-v2-editor-elements-panel`**. **PHP side — enqueue into the editor only:** ```php add_action( 'elementor/editor/before_enqueue_scripts', 'myplugin_enqueue_panel' ); function myplugin_enqueue_panel(): void { // The editor URL carries the post id; gate everything on it. $post_id = (int) get_the_ID(); if ( $post_id <= 0 && isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- identifying the open post, not processing a submission. $post_id = (int) $_GET['post']; } if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) { return; } $deps = [ 'react', 'react-dom' ]; // Only declare Elementor's handle when it exists, so an older build can't break the enqueue. if ( wp_script_is( 'elementor-v2-editor-elements-panel', 'registered' ) ) { $deps[] = 'elementor-v2-editor-elements-panel'; } wp_enqueue_script( 'myplugin-panel', MYPLUGIN_URL . 'assets/panel.js', $deps, MYPLUGIN_VERSION, true ); wp_enqueue_style( 'myplugin-panel', MYPLUGIN_URL . 'assets/panel.css', [], MYPLUGIN_VERSION ); wp_add_inline_script( 'myplugin-panel', 'window.myPluginPanel = ' . wp_json_encode( [ 'postId' => $post_id, 'restUrl' => esc_url_raw( rest_url( 'myplugin/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // send as X-WP-Nonce ] ) . ';', 'before' ); } ``` **Register defensively — a thrown error kills the whole panel:** ```js function injectTab() { var api = window.elementorV2 && window.elementorV2.editorElementsPanel; if ( ! api || 'function' !== typeof api.injectTab ) { return false; } api.injectTab( { id: 'myplugin', label: 'My Plugin', component: MyPanelComponent } ); return true; } if ( ! injectTab() ) { // The elementorV2 packages may not have executed yet. Poll briefly, then give up QUIETLY: // on a build without the API, no tab is far better than an exception that breaks the panel. var tries = 0; var t = setInterval( function () { if ( injectTab() || ++tries > 60 ) { clearInterval( t ); } }, 100 ); } ``` > ⚠️ **Do NOT inject the tab by rewriting Elementor's rendered HTML.** A widely-shipped plugin > buffers `elementor/editor/footer` and `preg_replace()`s a `<button class="elementor-component-tab > elementor-panel-navigation-tab" data-tab="…">` in after the `data-tab="global"` one. It works > today and breaks silently the day Elementor renames a class — that plugin ships a fallback for > exactly that case. With `injectTab()` available there is no reason to take the risk. **No build step needed.** `window.React` is global in the editor, so `React.createElement` (with a local `var h = React.createElement`) gives you the whole component model with nothing to compile — which also keeps the shipped file readable for a wp.org reviewer. ### The two traps that lint clean and only show in a real editor **1. Anything the REST route calls must live OUTSIDE the `is_admin()` include block.** The panel reads and writes over REST, and **a REST request is not an admin request**. A rendering helper parked in an admin-only file (`meta-box.php` and friends) produces a fatal `Call to undefined function` the first time the endpoint runs — while the meta box using the same helper works perfectly. Keep shared presentation helpers in an always-loaded file. **2. The Elementor 4.x panel is LIGHT, not dark.** Style against Elementor's own custom properties and pick fallbacks that survive **both** themes: | Token | Value in 4.2 (light panel) | |---|---| | `--e-a-bg-default` | `#fff` | | `--e-a-color-txt` | `#515962` | | `--e-a-color-txt-muted` | `#818a96` | | `--e-a-border-color` | `#e6e8ea` | | `--e-a-bg-hover` / `--e-a-bg-active` | `#f1f2f3` / `#e6e8ea` | | `--e-a-color-info` | `#2563eb` | | `--e-a-btn-bg-primary` | **`#f3bafd`** — a pale pink that expects DARK text | - A `rgba(255,255,255,.12)` "subtle track/surface" fallback is **invisible** on the light panel. Use **neutral grey alpha** (`rgba(127,127,127,.2)`) so it reads on white *and* on dark. - White label text on `--e-a-btn-bg-primary` is unreadable — that token is pale pink. For a solid primary button use `var( --e-a-color-info, #2563eb )` with `#fff`. - Light-on-dark semantic colours (`#f0a3a3`, `#7fd0a8`…) vanish on white. Use mid-tones (`#c53030`, `#a97a12`, `#1a7f52`) that hold up in both. - **Verify by reading computed styles in a live editor**, not by eye — both of these pass every linter and look fine in the source. --- ## 6. Hooks quick-reference (the extension surface) **PHP — registration & rendering:** | Hook | Use | |---|---| | `elementor/widgets/register` | Register custom widgets (since 3.5; old: `widgets_registered`) | | `elementor/elements/categories_registered` | Register a widget panel category | | `elementor/dynamic_tags/register` | Register dynamic tags + tag groups | | `elementor/query/{query_id}` | Filter a Loop Grid / Posts query by Query ID | | `elementor/element/{el}/{section}/before_section_end` | Inject a control into a native widget | | `elementor/widget/render_content` (filter) | Modify a widget's rendered HTML | | `elementor/frontend/the_content` (filter) | Modify rendered Elementor page content | | `elementor/theme/register_conditions` | Custom Theme Builder display conditions (Pro) | | `elementor/theme/register_locations` | Custom Theme Builder locations (Pro) | | `elementor_pro/forms/actions/register` | Custom form submit action (Pro) | | `elementor_pro/forms/fields/register` | Custom form field type (Pro) | | `elementor_pro/forms/validation` / `process` / `new_record` | Form submission lifecycle (Pro) | | `elementor/finder/register` | Add Finder items | | `elementor/frontend/after_register_scripts` / `after_enqueue_styles` | Frontend asset timing | | `elementor/editor/after_enqueue_scripts` / `after_enqueue_styles` | Editor-only assets | | `elementor/editor/before_enqueue_scripts` | Editor assets, early — use for a panel-tab bundle (§5) | | `elementor/preview/enqueue_styles` | Preview-iframe-only assets | **JS — editor & frontend:** | Hook | Use | |---|---| | `elementor/frontend/init` (bind with **jQuery** — see `js-css-standards.md`) | Frontend boot | | `frontend/element_ready/{widget}.default` | Per-widget frontend handler (incl. AJAX-loaded) | | `panel/open_editor/widget/{widget}` | Editor panel opened for a widget | | `elements/widget/contextMenuGroups` (filter) | Editor context-menu items | | `elementorV2.editorElementsPanel.injectTab()` (4.x API, not a hook) | Add a tab to the Elements panel (§5) | | `panel/elements/regionViews` (filter) | Low-level panel regions — `injectTab()` wraps this; prefer it | --- ## 7. Deprecations to avoid These still "work" but are deprecated — using them invites breakage and Plugin Check / review flags. Use the right-hand column. | Deprecated | Use instead | Since | |---|---|---| | `get_id_int()` | `get_id()` | 3.1 | | `_register_controls()` | `register_controls()` | 3.1 | | `_content_template()` | `content_template()` | 3.1 | | `$depended_scripts` / `$depended_styles` properties | `get_script_depends()` / `get_style_depends()` methods | 3.24 / Pro 3.28 | | `Scheme_Color` / `Scheme_Typography`, `Group_Control_Scheme_*` | `Global_Colors` / `Global_Typography` globals | 3.0 | | `elementor/widgets/widgets_registered` | `elementor/widgets/register` | 3.5 | | Targeting `.elementor-widget-container` in CSS/JS | the widget root / your own BEM class (+ `has_widget_inner_wrapper(): false`) | 3.25+ / V4 | | `window.addEventListener('elementor/frontend/init', …)` as the *only* binding | `jQuery(window).on('elementor/frontend/init', …)` | — | > Elementor's own deprecation log: developers.elementor.com/docs/deprecations/ — check it each > major release; deprecated APIs get a `_deprecated_*` notice under `WP_DEBUG`. --- ## 8. Newer control types (4.x) — `VISUAL_CHOICE` A V3-compatible control added in the 4.x line: an **image-based** choice picker (each option is a visual/SVG, not just an icon-glyph like `CHOOSE`). Ideal for layout / skin / structure pickers. ```php $this->add_control( 'structure', [ 'label' => esc_html__( 'Layout', 'myplugin' ), 'type' => \Elementor\Controls_Manager::VISUAL_CHOICE, 'default' => 'grid', 'label_block' => true, 'columns' => 2, // grid columns in the panel 'options' => [ 'grid' => [ 'title' => esc_attr__( 'Grid', 'myplugin' ), 'image' => plugins_url( 'assets/img/layout-grid.svg', MYPLUGIN_FILE ), ], 'masonry' => [ 'title' => esc_attr__( 'Masonry', 'myplugin' ), 'image' => plugins_url( 'assets/img/layout-masonry.svg', MYPLUGIN_FILE ), ], ], // Like CHOOSE, you can drive CSS directly when the value maps to a class/value: 'prefix_class' => 'myplugin-layout-', // → 'myplugin-layout-grid' on the wrapper ] ); // render(): the value is the selected option KEY (a string). $layout = $this->get_settings_for_display( 'structure' ); // 'grid' | 'masonry' ``` - Value is the **option key string** (read with `get_settings_for_display('structure')`). - `image` is a URL — ship your own SVGs; don't `wp_kses()` them (see `field-notes.md` §6 / §2 on the `viewBox` lowercasing trap if you ever inline them). - Compare with **`CHOOSE`** (icon-glyph toggle, e.g. alignment) and **`SELECT`/`SELECT2`** (plain dropdown) — pick `VISUAL_CHOICE` only when an image genuinely communicates the option better. > Source: developers.elementor.com/docs/editor-controls/control-visual-choice/ -
elementor-patterns.md 28.5 KB
# Elementor-Specific Patterns ## V4 Compatibility Rules (Apply Now) > ✅ **V4 / Elementor 4.x is STABLE (current: 4.2) — apply these rules to all new code:** > 1. **NEVER target `.elementor-widget-container` in CSS or JS** — does NOT exist in V4 > Atomic Elements; removed from V3 widgets when Optimized Markup is active (opt-in in > 3.35.x, **on by default for all new sites since Elementor 4.0**). Write all new code > as if this wrapper is absent. > Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ > 2. **ALWAYS include `has_widget_inner_wrapper(): false`** on every new widget. > 3. **Do NOT use `strategy: 'defer'`** on scripts that listen for `elementor/frontend/init`. > 4. **V3 `Widget_Base` is fully supported in Elementor 4.0.** Use V3 for all third-party > widgets — it is the correct, production-safe API. The V4 Atomic Element PHP extension > API is Stable in 4.0 but third-party PHP extension documentation is still being finalized. > Continue using V3 `Widget_Base` until official V4 extension docs are published. > Source: elementor.com/products/website-builder/v4-faq/ · developers.elementor.com/elementor-editor-4-0-developers-update/ > 5. **Elementor 4.0+ Admin Menu:** Register plugin menus as standard WordPress admin pages > outside the Elementor parent slug. Non-standard injection into the Elementor admin area > is silently dropped (unchanged from 3.34.2 behaviour). --- ## Mandatory: No Hardcoded Visuals — Every Widget Must Use Controls > ⚠️ **This is a MANDATORY rule — see SKILL.md §0 Golden Rule #6 and §5 for the full checklist.** > > **NEVER hardcode** colors, fonts, sizes, spacing, backgrounds, borders, shadows, or any > visual property in `render()`, in static CSS files, or in `content_template()`. ALL visual > properties must be Elementor controls using `selectors` to inject CSS. > > **For every text element:** `Group_Control_Typography` + `COLOR` control. > **For every box/container:** `Group_Control_Background` + `Group_Control_Border` + > `Group_Control_Box_Shadow` + `DIMENSIONS` (padding/margin/border-radius). > **For every spacing value:** `add_responsive_control()` with `SLIDER` or `DIMENSIONS`. > **For every interactive state:** Duplicate controls for `:hover` in a separate section. > > Source: developers.elementor.com/docs/widgets/widget-controls/ > Source: developers.elementor.com/docs/editor-controls/group-control/ > Source: developers.elementor.com/docs/editor-controls/responsive-control/ --- ## Custom Widget — Complete Pattern **Step 1 — Plugin header must declare Elementor dependency (WP 6.5+):** ```php /** * Plugin Name: My Plugin * Requires Plugins: elementor * Requires PHP: 8.3 * Requires at least: 6.9 */ ``` > ⚠️ Do NOT add inline comments on the `Requires Plugins:` line — `get_file_data()` reads > everything after the colon as the value. Keep the `did_action('elementor/loaded')` runtime > check (see scaffolding.md) as a fallback for pre-6.5 sites. **Step 2 — Register widget assets on `wp_enqueue_scripts` (register only — do NOT enqueue):** ```php // ⚠️ DO NOT add strategy:'defer' to widget scripts that use elementor/frontend/init. // Use ['in_footer' => true] WITHOUT strategy:defer. The 'elementor-frontend' dependency // guarantees document-order execution AFTER Elementor's script. add_action( 'wp_enqueue_scripts', function() { wp_register_script( 'myplugin-widget-js', MYPLUGIN_URL . 'assets/js/myplugin-widget.js', [ 'elementor-frontend' ], // ✅ dependency guarantees load order MYPLUGIN_VERSION, [ 'in_footer' => true ] // ✅ no defer — see note above ); wp_register_style( 'myplugin-widget-css', MYPLUGIN_URL . 'assets/css/myplugin-widget.css', [], MYPLUGIN_VERSION ); } ); ``` **Step 3 — Widget class with all required methods:** ```php // includes/class-myplugin-widget.php defined( 'ABSPATH' ) || exit; class MyPlugin_Widget extends \Elementor\Widget_Base { public function get_name(): string { return 'myplugin-widget'; } public function get_title(): string { return esc_html__( 'My Widget', 'myplugin' ); } public function get_icon(): string { return 'eicon-code'; } public function get_categories(): array { return [ 'general' ]; } public function get_keywords(): array { return [ 'custom', 'myplugin' ]; } // ✅ REQUIRED: Declare JS dependencies — Elementor loads these only on pages using this widget public function get_script_depends(): array { return [ 'myplugin-widget-js' ]; } // ✅ REQUIRED: Declare CSS dependencies public function get_style_depends(): array { return [ 'myplugin-widget-css' ]; } // ✅ REQUIRED on ALL new widgets — removes the redundant inner wrapper div // (.elementor-widget-container). Return true ONLY if your render() output // physically requires that inner wrapper. public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false if widget renders static content — enables Elementor output caching. // Return true if output depends on current user, session, time, or other dynamic data. protected function is_dynamic_content(): bool { return false; } protected function register_controls(): void { $this->start_controls_section( 'section_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Default Title', 'myplugin' ), 'label_block' => true, 'frontend_available' => true, ] ); $this->end_controls_section(); $this->start_controls_section( 'section_style', [ 'label' => esc_html__( 'Title Style', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); // ✅ RESPONSIVE alignment — user sets per-breakpoint via device icons $this->add_responsive_control( 'title_align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-widget__title' => 'text-align: {{VALUE}};', ], ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ // ✅ {{WRAPPER}} resolves to .elementor-widget-myplugin-widget (widget root) // NEVER use .elementor-widget-container here — see V4 rules above '{{WRAPPER}} .myplugin-widget__title' => 'color: {{VALUE}};', ], ] ); // ✅ Group_Control_Typography — MANDATORY for every text element. // Provides font-family, size, weight, transform, style, decoration, line-height, // letter-spacing, and word-spacing — all responsive. NEVER hardcode any of these. $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'selector' => '{{WRAPPER}} .myplugin-widget__title', ] ); // ✅ Group_Control_Text_Shadow — standard for heading/title elements $this->add_group_control( \Elementor\Group_Control_Text_Shadow::get_type(), [ 'name' => 'title_text_shadow', 'selector' => '{{WRAPPER}} .myplugin-widget__title', ] ); // ✅ RESPONSIVE spacing — use SLIDER with size_units for any spacing value $this->add_responsive_control( 'title_spacing', [ 'label' => esc_html__( 'Bottom Spacing', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'size_units' => [ 'px', 'em', 'rem' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ], 'em' => [ 'min' => 0, 'max' => 10 ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-widget__title' => 'margin-bottom: {{SIZE}}{{UNIT}};', ], ] ); $this->end_controls_section(); } protected function render(): void { $settings = $this->get_settings_for_display(); // ✅ Null-safe check — Plugin::$instance->editor is null on the frontend $is_editor = \Elementor\Plugin::$instance->editor && \Elementor\Plugin::$instance->editor->is_edit_mode(); // ✅ ALWAYS use add_render_attribute() — the official Elementor API for building // HTML attributes. Never manually concatenate class strings in render(). // Source: developers.elementor.com/docs/widgets/rendering-html-attribute/ $this->add_render_attribute( 'title', 'class', 'myplugin-widget__title' ); // ✅ add_inline_editing_attributes() enables live text editing in the panel. // Source: developers.elementor.com/docs/widgets/rendering-inline-editing/ $this->add_inline_editing_attributes( 'title' ); ?> <div class="myplugin-widget"> <h2 <?php $this->print_render_attribute_string( 'title' ); ?>> <?php echo esc_html( $settings['title'] ); ?> </h2> <?php if ( $is_editor ) : ?> <span class="myplugin-widget__editor-hint" style="opacity:0.5;font-size:0.75rem;"> <?php esc_html_e( '[Editor preview]', 'myplugin' ); ?> </span> <?php endif; ?> </div> <?php } // ✅ REQUIRED: JS/Backbone template for live editor preview. // ⚠️ ESCAPING: use {{ }} (escaped) for USER SETTINGS — title, text, labels, button text. // {{{ }}} (raw) on a user-controlled value is an editor-context XSS and wp.org plugin // review REJECTS it. {{ }} escapes via _.escape(); the browser decodes the entity on // render (O'Reilly displays correctly — there is NO apostrophe corruption). // Reserve {{{ }}} for Elementor-GENERATED HTML only (e.g. {{{ iconHTML.value }}} from // elementor.helpers.renderIcon, processed media) — never raw settings, and never inside // an HTML attribute (a triple-brace breaks the attribute). See field-notes.md §2. // getRenderAttributeString() output IS safe Elementor-built markup → triple-brace is OK. protected function content_template(): void { ?> <div class="myplugin-widget"> <# view.addRenderAttribute( 'title', 'class', 'myplugin-widget__title' ); view.addInlineEditingAttributes( 'title' ); #> <h2 {{{ view.getRenderAttributeString( 'title' ) }}}>{{ settings.title }}</h2> </div> <?php } } // ✅ REQUIRED registration hook add_action( 'elementor/widgets/register', function( \Elementor\Widgets_Manager $manager ) { require_once MYPLUGIN_PATH . 'includes/class-myplugin-widget.php'; $manager->register( new MyPlugin_Widget() ); } ); ``` --- ## Dynamic Tag Registration > **Dynamic Tag Category Reference (current as of Elementor 4.2; unchanged since 3.x):** > > | Constant | Value | Available in | Use for | > |---|---|---|---| > | `Module::TEXT_CATEGORY` | `'text'` | **Free** | Single-line text fields | > | `Module::URL_CATEGORY` | `'url'` | **Free** | URL/link controls | > | `Module::IMAGE_CATEGORY` | `'image'` | **Free** | Media/image controls | > | `Module::MEDIA_CATEGORY` | `'media'` | **Free** | Alias for IMAGE_CATEGORY | > | `Module::NUMBER_CATEGORY` | `'number'` | **Free** | Slider/number controls | > | `Module::COLOR_CATEGORY` | `'color'` | **Free** | Color controls | > | `Module::POST_GROUP` | `'post'` | **Pro only** | ⚠️ Fatal error without Pro | > | `Module::SITE_GROUP` | `'site'` | **Pro only** | ⚠️ Fatal error without Pro | > > Always use the class constant (`Module::TEXT_CATEGORY`) not the raw string — the string > values are internal and could change. Source: `elementor/modules/dynamic-tags/module.php` > ⚠️ **Pick the right PARENT or the tag silently returns nothing.** There are two, and the > choice is dictated by the category: > > | Parent | `get_content_type()` | How Elementor resolves it | Use for | > |---|---|---|---| > | `\Elementor\Core\DynamicTags\Tag` | `'ui'` (**`final`**) | `ob_start(); $this->render();` | TEXT, URL, COLOR, NUMBER | > | `\Elementor\Core\DynamicTags\Data_Tag` | `'plain'` (**`final`**) | `return $this->get_value( $options );` | **IMAGE / MEDIA / GALLERY** | > > An image tag written as `extends Tag` with a `get_value()` returning `['id','url']` looks > completely correct and **never runs**: `get_content_type()` is `final`, so the tag stays > 'ui', Elementor output-buffers `render()`, and `get_value()` is dead code. The image control > receives an empty string and the field appears to have no value — with no error anywhere. > Symptom to recognise: *"dynamic fields can't pull the images"* while text tags work fine. > > `Data_Tag::get_value()` is declared `abstract protected`, so match that visibility. Return > `[ 'id' => (int) $attachment_id, 'url' => (string) $url ]`, and fall back to > `\Elementor\Utils::get_placeholder_image_src()` when the attachment is gone — otherwise a > deleted image emits a broken `src`. > > Because the two parents are incompatible, put shared field-lookup logic in a **trait**, not a > common base class: > ```php > trait My_Tag_Fields { /* register_controls(), field_value(), get_group() … */ } > abstract class My_Tag_Base extends \Elementor\Core\DynamicTags\Tag { use My_Tag_Fields; } > class My_Image_Tag extends \Elementor\Core\DynamicTags\Data_Tag { use My_Tag_Fields; } > ``` > Sanity check for `Tag`-based tags: `Tag::WRAPPED_TAG` defaults to **`false`**, so URL and > COLOR output is not span-wrapped. If a subclass sets it `true`, a colour or URL tag returns > HTML and the CSS value breaks. > ⚠️ **An empty image field must return an EMPTY value — never a placeholder.** > `Utils::get_placeholder_image_src()` looks like a helpful fallback and is the wrong call: > Elementor's image widget opens with > `if ( empty( $settings['image']['url'] ) ) { return; }` (`includes/widgets/image.php`), so an > empty `url` is precisely what makes it render nothing. Return a placeholder instead and every > unset field paints a grey Elementor placeholder on the front end, which the site owner then > has to hide with custom CSS. > > This bites hardest on **repeated optional slots** — `cert_img_1..4`, gallery slots, secondary > logos. A field sanitised with `absint` stores **`0`** when nothing is chosen, and `0` is not an > image; an instructor with two certificates renders two real images and two placeholders. > > **Elementor Pro's own `ACF_Image` tag is the reference implementation** > (`elementor-pro/modules/dynamic-tags/acf/tags/acf-image.php`): > ```php > $image_data = [ 'id' => null, 'url' => '' ]; // starts EMPTY > // …resolve the field… > if ( empty( $value ) && $this->get_settings( 'fallback' ) ) { > $value = $this->get_settings( 'fallback' ); // only a USER-CHOSEN fallback > } > if ( ! empty( $value ) && is_array( $value ) ) { /* fill id + url */ } > return $image_data; // empty when nothing is set > ``` > It also registers a `Controls_Manager::MEDIA` control named `fallback`, so the *user* decides > whether an empty field shows something. Copy that shape: empty by default, opt-in fallback. > Treat a set id whose attachment was deleted the same way — asserting an image exists when it > does not is the bug, not the cure. > > Same principle as the colour-control rule in **SKILL.md §5**: do not invent a default the user > cannot switch off. If the platform ships a reference implementation for the thing you are > building, read it before inventing behaviour. > 🎠 **Carousels and sliders accept ONLY the GALLERY category — an IMAGE tag cannot fill them.** > So a set of separate single-image fields (`cert_img_1..4`, `logo_a`/`logo_b`, gallery slots on a > repeater) is unusable in a slider until you add a tag that synthesises them into one gallery. > `Module::GALLERY_CATEGORY` is defined in **free** Elementor, so this needs no Pro. Gallery tags > extend **`Data_Tag`**, same as image tags, for the same `get_content_type()` reason. > > **Return a flat list of `array( 'id' => int )`.** `id` is mandatory — verified against the > consumers, not assumed from one example: > > | Consumer | How it reads each item | > |---|---| > | Elementor **Pro** Gallery | `wp_get_attachment_image_src( $item['id'], … )` — **id only, no url fallback** | > | free Image Gallery | `wp_list_pluck( $items, 'id' )` | > | free Image Carousel | `Group_Control_Image_Size::get_attachment_image_src( $item['id'], … )`, reads `$item['url']` **only if that returns false** | > | Pro `ACF_Gallery` (reference) | returns `[ 'id' => $image['ID'] ]` | > > Captions, alt text and lightbox links all key off `id` too, so an id-less item degrades badly. > Adding `url` alongside `id` is harmless — no first-party widget prefers it over its own sized > version — and is worth doing defensively, because a third-party carousel that naively reads > `$item['url']` renders blank slides otherwise. > > Skip empty slots and ids whose attachment no longer resolves, and return an **empty array** when > there is nothing: a carousel then renders nothing instead of blank slides. Derive the slot list > from your field schema rather than hardcoding it, so adding a fifth slot needs no tag change. > > **Method worth repeating:** the reference implementation tells you what is *sufficient*; the > consumers tell you what is *required*. ACF's gallery tag returns id-only, which is sufficient — > but only reading Pro's Gallery widget proves `id` is mandatory with no fallback, and only > surveying third-party widgets justifies carrying `url` as well. Check both ends. > > Also worth checking before you build: whether the system you are replacing solved the problem at > all. `ACF_Gallery::get_supported_fields()` returns `['gallery']`, so on a site whose field group > has only `image` fields it offers an empty picker — the capability never existed, and you are > adding it rather than porting it. ```php // ✅ Type hint \Elementor\Core\DynamicTags\Manager (different from Widgets_Manager) add_action( 'elementor/dynamic_tags/register', function( \Elementor\Core\DynamicTags\Manager $manager ) { // ✅ Register the custom group BEFORE registering the tag that references it. // ⚠️ Do NOT use \Elementor\Modules\DynamicTags\Module::POST_GROUP here. // POST_GROUP is Elementor Pro-only — causes PHP fatal error if Pro is not active. $manager->register_group( 'myplugin', [ 'title' => esc_html__( 'My Plugin', 'myplugin' ), ] ); require_once MYPLUGIN_PATH . 'includes/class-myplugin-dynamic-tag.php'; $manager->register( new MyPlugin_Dynamic_Tag() ); } ); class MyPlugin_Dynamic_Tag extends \Elementor\Core\DynamicTags\Tag { public function get_name(): string { return 'myplugin-tag'; } public function get_title(): string { return esc_html__( 'My Custom Tag', 'myplugin' ); } // ✅ get_group() MUST return array — returning a string causes PHP TypeError public function get_group(): array { return [ 'myplugin' ]; } public function get_categories(): array { // ✅ TEXT_CATEGORY IS defined in free Elementor — safe to use without Pro return [ \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ]; } public function render(): void { $value = get_post_meta( get_the_ID(), '_my_custom_field', true ); echo esc_html( (string) $value ); } } ``` --- ## Loop Grid — Custom Query Filter ```php // In Elementor editor → Query ID field: set to "myplugin_loop_query" add_action( 'elementor/query/myplugin_loop_query', function( \WP_Query $query ) { $query->set( 'post_type', 'product' ); $query->set( 'posts_per_page', 6 ); $query->set( 'no_found_rows', true ); // skip COUNT(*) when no pagination needed $query->set( 'tax_query', [ [ 'taxonomy' => 'product_cat', 'field' => 'slug', 'terms' => [ 'featured' ], ] ] ); } ); ``` --- ## ACF + Elementor Dynamic Tag (Full Pattern) ```php class MyPlugin_ACF_Tag extends \Elementor\Core\DynamicTags\Tag { public function get_name(): string { return 'myplugin-acf-tag'; } public function get_title(): string { return esc_html__( 'ACF Field', 'myplugin' ); } public function get_group(): array { return [ 'myplugin' ]; } public function get_categories(): array { return [ \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ]; } protected function register_controls(): void { $this->add_control( 'field_key', [ 'label' => esc_html__( 'ACF Field Key or Name', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, ] ); } public function render(): void { $field_key = $this->get_settings( 'field_key' ); if ( empty( $field_key ) || ! function_exists( 'get_field' ) ) return; // ✅ sanitize_text_field() NOT sanitize_key() — sanitize_key() lowercases and // strips non-[a-z0-9_-] characters, silently corrupting mixed-case ACF field names. $value = get_field( sanitize_text_field( $field_key ) ); // get_field() can return arrays (repeaters) — guard against it if ( is_array( $value ) || is_object( $value ) ) return; echo esc_html( (string) $value ); } } add_action( 'elementor/dynamic_tags/register', function( \Elementor\Core\DynamicTags\Manager $manager ) { // register_group() is idempotent — safe to call in multiple callbacks $manager->register_group( 'myplugin', [ 'title' => esc_html__( 'My Plugin', 'myplugin' ), ] ); require_once MYPLUGIN_PATH . 'includes/class-myplugin-acf-tag.php'; $manager->register( new MyPlugin_ACF_Tag() ); } ); ``` --- ## Elementor Pro Form — Custom Action (Elementor Pro 3.28+) Elementor Pro 3.28 (March 2025) updated the Form **Field** API (`Field_Base`) so fields can declare JS/CSS dependencies via methods (identical to widget `get_script_depends()`) instead of the old `$depended_scripts` / `$depended_styles` properties (now deprecated on `Field_Base`). Form Actions (`Action_Base`) are not part of this documented change. ```php // Requires: Elementor Pro class MyPlugin_Form_Action extends \ElementorPro\Modules\Forms\Classes\Action_Base { public function get_name(): string { return 'myplugin-action'; } public function get_label(): string { return esc_html__( 'My Plugin Action', 'myplugin' ); } public function get_script_depends(): array { return [ 'myplugin-form-js' ]; } public function get_style_depends(): array { return [ 'myplugin-form-css' ]; } // ✅ Type hint omitted intentionally — Elementor does not expose a public interface/class // for the $widget parameter. Using \Elementor\Widget_Base is the closest match but // Elementor passes its internal form widget class. Omit type hint to avoid TypeError. public function register_settings_section( $widget ): void { $widget->start_controls_section( 'myplugin_action_section', [ 'label' => esc_html__( 'My Plugin', 'myplugin' ), 'condition' => [ 'submit_actions' => $this->get_name() ], ] ); $widget->add_control( 'myplugin_endpoint', [ 'label' => esc_html__( 'Endpoint URL', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, ] ); $widget->end_controls_section(); } public function on_export( array $element ): array { return $element; } // $record = \ElementorPro\Modules\Forms\Classes\Form_Record // $ajax_handler = \ElementorPro\Modules\Forms\Classes\Ajax_Handler // Both classes are Pro-only — do NOT type hint them directly here or PHP // will throw a fatal error on sites where Elementor Pro is inactive. public function run( $record, $ajax_handler ): void { $settings = $record->get( 'form_settings' ); $endpoint = sanitize_url( $settings['myplugin_endpoint'] ?? '' ); if ( empty( $endpoint ) ) return; // ✅ $record->get('fields') returns an associative array keyed by field ID. // Loop key $id IS the field ID — do NOT use $field['id']. $raw_fields = $record->get( 'fields' ); $payload = []; foreach ( $raw_fields as $id => $field ) { $payload[ sanitize_key( $id ) ] = sanitize_text_field( $field['value'] ); } $response = wp_remote_post( $endpoint, [ 'body' => wp_json_encode( $payload ), 'headers' => [ 'Content-Type' => 'application/json' ], 'timeout' => 15, ] ); if ( is_wp_error( $response ) ) { $ajax_handler->add_error_message( esc_html__( 'Submission failed. Please try again.', 'myplugin' ) ); } } } add_action( 'elementor_pro/forms/actions/register', function( $form_actions_registrar ) { require_once MYPLUGIN_PATH . 'includes/class-myplugin-form-action.php'; $form_actions_registrar->register( new MyPlugin_Form_Action() ); } ); ``` --- ## Elementor Theme Builder — Custom Display Conditions > ⚠️ **API scope:** Only registering **new condition types** via > `elementor/theme/register_conditions` is the public API. Programmatically > **assigning** conditions to an existing template is undocumented internal operation. ```php // Requires: Elementor Pro class MyPlugin_Logged_In_Condition extends \ElementorPro\Modules\ThemeBuilder\Conditions\Condition_Base { // 'general' = site-wide; 'singular' = singular posts/pages; 'archive' = archive pages public static function get_type(): string { return 'general'; } public function get_name(): string { return 'myplugin_logged_in'; } public function get_label(): string { return esc_html__( 'Logged-In User', 'myplugin' ); } public function get_all_label(): string { return esc_html__( 'All Logged-In Users', 'myplugin' ); } public function check( $args ): bool { return is_user_logged_in(); } } add_action( 'elementor/theme/register_conditions', function( $conditions_manager ) { require_once MYPLUGIN_PATH . 'includes/class-myplugin-logged-in-condition.php'; $conditions_manager ->get_condition( 'general' ) ->register_sub_condition( new MyPlugin_Logged_In_Condition() ); } ); ``` **Custom condition with role selector:** ```php class MyPlugin_User_Role_Condition extends \ElementorPro\Modules\ThemeBuilder\Conditions\Condition_Base { public static function get_type(): string { return 'general'; } public function get_name(): string { return 'myplugin_user_role'; } public function get_label(): string { return esc_html__( 'User Role', 'myplugin' ); } public function get_all_label(): string { return esc_html__( 'All Roles', 'myplugin' ); } protected function register_sub_conditions(): void { global $wp_roles; $roles = []; foreach ( $wp_roles->roles as $slug => $role ) { $roles[ $slug ] = translate_user_role( $role['name'] ); } $this->add_control( 'myplugin_role_select', [ 'section' => 'settings', 'label' => esc_html__( 'Role', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => $roles, ] ); } public function check( $args ): bool { $required_role = $args['id'] ?? ''; if ( ! $required_role || ! is_user_logged_in() ) return false; $user = wp_get_current_user(); return in_array( $required_role, (array) $user->roles, true ); } } ``` > **Programmatic condition assignment (internal — use only for one-time migration scripts):** > ```php > update_post_meta( $template_id, '_elementor_conditions', [ > [ 'name' => 'general', 'id' => '', 'sub_name' => '', 'sub_id' => '' ], > ] ); > delete_transient( 'elementor_pro_theme_builder_conditions' ); > ``` -
field-notes.md 58.4 KB
# Field Notes — Hard-Won Production Gotchas > **When to read this file:** Alongside the topic files whenever building a custom widget, plugin, > or wp.org-bound work — this is the "why it actually breaks" layer. Every rule exists because the > opposite **shipped and broke something**; these are the bugs that pass `php -l` / `node --check`, > survive code review, and only surface on a live page or in a wp.org submission. Lessons distilled from shipping real WordPress/Elementor plugins (auth forms, an embedded exam app). --- ## 1. Elementor widget lifecycle — the fatal & the silent - **An untyped override of a typed parent method is a site-down fatal.** Elementor declares `has_widget_inner_wrapper(): bool`, `is_dynamic_content(): bool`, `get_categories(): array`, etc. Overriding one **without the matching return type** is a PHP fatal that white-screens **every page** (these run on `wp_enqueue_scripts`). `php -l` checks syntax only — it does **not** catch a signature mismatch. Always copy the parent's return type exactly, and instantiate the widget against a **typed `\Elementor\Widget_Base` stub** before shipping to catch it at class-declaration time. - **`render()` runs on EVERY control change in the editor** — dozens of times per session. Any `add_filter()` / `add_action()` you set *inside* `render()` must be removed immediately after (add → render → remove, request-scoped). Leaked anonymous-closure filters stack up across re-renders, producing duplicated/mangled output and "undefined index" notices that crash the preview iframe. - **`get_name()` is sticky — renaming it breaks every existing page.** The widget name is stored as `"widgetType"` inside each page's `_elementor_data` JSON. Change it and every placed instance renders as "widget not found." When refactoring/prefixing, **keep `get_name()`** and rename only the PHP class. If you truly must rename it, migrate the stored `_elementor_data` tokens **and** the `frontend/element_ready/<name>.default` hook names in JS together. - **Pre-register asset handles before declaring them.** Handles returned from `get_style_depends()` / `get_script_depends()` must already be `wp_register_style/script`-ed (on `wp_enqueue_scripts`) or Elementor's dependency enqueue **silently no-ops** — your CSS/JS never loads and there's no error. - **Registration plumbing is guard-heavy on purpose.** Register widget *instances* on `elementor/widgets/register`; register the category on `elementor/elements/categories_registered`. Gate `require_once` with `did_action('elementor/loaded')` **and** `class_exists('\Elementor\Widget_Base')`. Wrap optional group controls (e.g. `Group_Control_Box_Shadow`) in `class_exists()` for cross-version safety. (If preload/opcache/static-analysis chokes on `\Elementor\*` type hints in a hook callback, drop the hint and verify with `class_exists()` inside instead.) - **Detect companion/sibling plugins at RUNTIME, never at file-load.** Plugins load in alphabetical order, so a constant/class another plugin defines may not exist yet when your file is parsed — a file-scope `defined()` check silently returns false for any plugin that sorts after yours. Check `defined()` / `class_exists()` from `plugins_loaded` (or later), inside the behaviour that needs it. - **A new UI section inside a widget MUST ship its own Style controls (Golden Rule #6).** Adding markup with CSS-only and no controls means its heading/colours inherit the theme with no way to change them — exactly the "I can't style this" complaint. Every new section needs a matching control group (heading colour/typography/alignment, body text, bg/border, links), and any new button must reuse an already-controlled class or get its own controls. --- ## 2. `content_template()` escaping — the editor-XSS that wp.org rejects > ⚠️ **Use `{{ }}` (escaped) for user settings — NOT `{{{ }}}`.** This corrects the instinct to > reach for triple-brace "to avoid double-encoding." - **`{{{ settings.* }}}` on a user-controlled value is an editor-context XSS** and **wp.org plugin review rejects it** (real rejection on `WordPress.Security.EscapeOutput`). `{{{ }}}` injects raw HTML into the Backbone preview; `{{ }}` runs it through `_.escape()`. Escaped output renders **correctly** in the browser — `O'Reilly` → `O'Reilly` displays as `O'Reilly` (the entity decodes on render). There is **no apostrophe corruption**; that was a myth. - **Rule:** `{{ }}` for ALL interpolated settings — titles, labels, button text, plain `TEXT`/`TEXTAREA`. Reserve `{{{ }}}` **only** for Elementor-generated HTML that is not raw user input: `{{{ iconHTML.value }}}` from `elementor.helpers.renderIcon( ... )`, and processed media. Even inside HTML **attributes**, use `{{ }}` — a triple-brace in an attribute breaks the attribute. - **Plugin Check flags the PHP `echo $this->method()` wrapper, not the brace.** A method that *returns* a built HTML string which is then `echo`'d is always flagged. Fix the **source**: make the method echo internally (literals + `esc_*`) and call it as a bare statement. A `phpcs:ignore … "escaped during construction"` here **hides** the real `{{{ }}}` issue — reviewers grep for exactly those justification phrases. --- ## 3. Output escaping & the wp.org "escape on output" review - **Escape LATE, at the point of output — building a value "safely" earlier does not count.** Every `$`-variable, option, and generated value is escaped where it is echoed, with the context-appropriate function (`esc_html` / `esc_attr` / `esc_url` / `esc_textarea` / `wp_kses` / `wp_kses_post`, or the `esc_html__()` / `esc_html_e()` i18n variants). Source: developer.wordpress.org/apis/security/escaping/ - **Plugin Check flags `echo $var` / `echo $this->method()` / `echo func()`** — it does **not** flag string literals or `esc_*()` / `wp_kses*()` calls. Fix a flagged echo by wrapping the dynamic part, or by restructuring so the echo is literals + escapers only. - **`phpcs:ignore` is only for genuine false positives with a true, verifiable reason** (e.g. "static inline SVG, no user input"). Never use it to silence a real finding — reviewers literally grep for the justification text. When in doubt, fix the source. - **Justified, unavoidable false positives that DO get a tagged ignore:** - `NonPrefixedHooknameFound` on **core** hooks you fire/filter — `do_action('wp_login', …)`, `apply_filters('login_redirect', …)`, `login_enqueue_scripts`, and third-party cache hooks you must match by exact name. - `NonPrefixedConstantFound` on cache opt-out signals — `DONOTCACHEPAGE`, `DONOTCACHEOBJECT`, `DONOTMINIFY`, `DONOTROCKETOPTIMIZE` (each behind `! defined()`); they MUST match the exact names cache plugins check, so they can't be prefixed. - **Inputs too:** `wp_unslash()` then sanitize on **every** superglobal read — including `$_SERVER`: `sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) )`. Plugin Check raises `MissingUnslash` + `InputNotSanitized` otherwise. - **Vendored libraries: don't edit them, disclose them.** A bundled MIT lib that trips dozens of `EscapeOutput`/`AlternativeFunctions` errors stays unmodified (so it keeps updating cleanly); exclude its directory from your Plugin Check run and note it in the review reply, *provided* its output is never echoed (e.g. all its exceptions are caught internally). --- ## 4. CSS architecture — the rules that bite inside Elementor - **Never style on bare ARIA attributes.** `[aria-hidden="true"] { display:none }` (e.g. to hide a honeypot) also hides required-field asterisks and decorative SVGs. Give the element its own class (`.myplugin-hp`) and style that. Found only by inspecting **computed styles in a live DOM** — screenshots "looked fine." - **CSS custom properties cascade parent → child only — never upward.** In Elementor the markup is often `.myplugin-wrap > .elementor-widget… .myplugin` and the **outer wrapper does not carry your root class**, so a rule on the wrapper that reads `var(--myplugin-card-bg)` resolves to nothing if the variable is defined only on the inner element. Declare every `--myplugin-*` variable on a selector that **includes the wrapper** (`.myplugin, .myplugin-wrap { … }`) or provide a `var(x, fallback)`. Reproduce by rendering an actual Elementor **widget**, not just a shortcode. - **A `min-width` floor on a flex child is a footgun.** A flex child holding unbreakable user data (a long email, a URL) with `flex:1; min-width:180px` cannot shrink below 180px → it overflows and collides with the next column. Flex children that hold user data get **`min-width:0`** (so they can shrink) + **`overflow-wrap:anywhere; word-break:break-word`** (so the string wraps). - **Body-appended UI escapes your scoped token container.** A modal/popup mounted via `document.body.appendChild` sits **outside** your `.myplugin-scope`, so its `var(--accent)` / `--fs-*` / fonts resolve to nothing and it renders unstyled. Give body-mounted UI its **own token block** (`.myplugin-modal-overlay { …same tokens… }`). Don't fix it by moving the node inside the scope — that subjects it to the scope's element resets. (Relevant to off-canvas panels, lightboxes, and any `position:fixed` overlay — see `offcanvas-ui.md`.) - **Style controls must target the OUTERMOST wrapper the widget owns.** Targeting an inner element leaves the title/border/card outside the styled area. Pair with the `{{WRAPPER}}` selector rules in `js-css-standards.md`. - **`!important` in YOUR OWN stylesheet silently kills the matching Elementor controls.** The usual warning is about `!important` vs *themes*; this is the reverse and much easier to ship. A rule written to neutralise a default look — ```css /* "this layout is a container, not a card — don't double-card it" */ .myplugin-form-account, .myplugin-wrap:has(.myplugin-form-account){background:none!important;border:0!important; border-radius:0!important;padding:0!important;box-shadow:none!important} ``` — also outranks the CSS Elementor generates, because Elementor's output carries no `!important`. In one case that single line disabled **five** controls at once (Background, Padding, Border, Radius, Shadow) on one widget while the same controls worked everywhere else. Nothing errors; the panel just does nothing. **The `!important` is almost never needed** — check the specificity first. Here `.myplugin-wrap:has(.myplugin-form-account)` is **(0,2,0)** and appears later in the file, so it already beat the base `.myplugin-wrap, .myplugin-form` rule at **(0,1,0)**; Elementor's selector is **(0,3,0)+** and wins over both. Deleting the five `!important`s preserved the default look *and* restored the controls. Rule of thumb: **a "reset/neutralise" rule on an element that also has style controls must win by specificity or order, never by `!important`.** Symptom to recognise: *"this one control does nothing on this one widget."* Verify by reading **computed** styles in a live DOM and listing which rules actually match the element — `[...document.styleSheets].flatMap(s=>[...s.cssRules]).filter(r=>r.selectorText&&el.matches(r.selectorText))` shows the `!important` rule immediately. - **Two control groups whose selectors overlap: at equal specificity the one registered LAST wins.** Elementor emits generated CSS in control-registration order, so a generic group (say "Action Links" → `.myplugin-link-button`) registered after a specific one ("Remove Link" → `.myplugin-item-remove`) will override it whenever an element carries **both** classes — the specific control silently loses. Don't reorder sections to fix it (fragile, and the panel order is a UX decision). **Scope the more specific control one level deeper** so it wins on specificity regardless of order: `{{WRAPPER}} .myplugin-panel .myplugin-item-remove`. Audit for this by listing every selector your controls target and checking for shared classes. - **`{{WRAPPER}}` selectors can't cross-reference another control's value.** A combined visual effect (e.g. focus-glow spread *and* colour) needs **two** controls feeding one selector. Use `CHOOSE` + `selectors_dictionary` for non-numeric CSS toggles (not `SELECT`). URL controls return an **array** (`['url' => …, 'is_external' => …]`), not a string — read `['url']`. - **A COLOUR control with a `default` can never be switched off.** Elementor emits a control's `default` exactly like a user-set value, so `'default' => '#6B4C9A'` paints that colour on every install and clearing the swatch just restores it. Users read that as "the widget forces a colour on me." **Elementor core carries no `default` on colour controls at all** — check `includes/widgets/heading.php`: `title_color` uses `'global' => ['default' => Global_Colors::COLOR_PRIMARY]` (a *reference* the user can clear) and `title_hover_color` is **completely empty — no default, no global**. Empty ⇒ nothing emitted ⇒ the stylesheet's resting value stands. So: **resting look lives in your CSS; state colours (hover / active / selected / current) are empty controls.** Two consequences that are easy to miss: - **State colours must write a direct CSS property, never a custom property.** `--my-btn-bg:{{VALUE}}` read back as `var(--my-btn-bg, red)` means the fallback paints whenever the control is empty — "default" becomes "red", not "off". `'{{WRAPPER}} .btn:hover' => 'background:{{VALUE}};'` emits *no rule at all* when empty, which is the only way default means off. - **Then your stylesheet must not declare that state either.** Leaving `.btn:hover { background: var(--my-btn-bg, rgba(255,255,255,.5)) }` in the CSS re-introduces the same problem from the other side. Keep hover/selected rules **colour-free** — carry the state with `font-weight` or an inset highlight so the control genuinely owns every colour. - **Theme form-control selectors outrank your bare classes.** `input[type="search"]:focus` is **0-2-1** (element + attribute + pseudo-class); your `.my-search:focus` is **0-2-0** — the theme wins. Astra ships exactly that rule and repaints focused inputs its own blue, which then persists until blur and reads as a bug in *your* widget. Same trap on `input[type="range"]::-webkit-slider-thumb` (0-1-1 beats `.my-range::-webkit-slider-thumb` at 0-1-0). Fix by **element-qualifying every interactive-control selector** — `input[type="search"].my-search:focus` (0-3-1), `.my-widget button.my-chip:hover` — which still sits far below Elementor's generated `.elementor-{id} .elementor-element.elementor-element-{id} .sel` (0-4-0+), so the controls keep winning. Reach for `!important` only after this fails; it would also lock the user's controls out. - **Elementor's GLOBAL KIT styles bare elements, and it beats your bare class.** Separate from the theme trap above: the active kit emits `.elementor-kit-{id} h2`, `.elementor-kit-{id} button`, `… input`, `… select` at **0-1-1**, so `.my-widget__title` (0-1-0) loses and your heading takes the kit's size, not yours. Measured on a dialog inside a widget: the heading resolved to **24px** and the buttons to **22px** against the ~15px the stylesheet asked for — which then wrapped a two-word button label onto two lines and read as "my CSS is being ignored". Element-qualify the same way as for the theme — `.my-panel h2.my-panel__title`, `.my-panel button.my-panel__copy` (0-2-1) — still far below the 0-4-0 a control writes. Diagnose it by listing every rule that matches the element and sets the property, rather than assuming your class won. - **`rem` is right for TYPE and wrong for a TAP TARGET.** The rule above still holds for text, but a 44px minimum touch target written as `2.75rem` inherits whatever the theme does to the root font size — and plenty of themes shrink it on small screens. Measured on one: the icon buttons came out **44x44 on desktop and 40x40 at 375px**, i.e. below the minimum *precisely* where fingers are used and nowhere else. Nothing in the editor shows it, because the desktop preview is correct. Write physical minimums in **px** (`var(--x-box, 44px)`), keep the token so a control can still override it in any unit, and check the computed box at a mobile width rather than trusting the desktop number. - **Size a widget's own UI in `rem`, never `em`.** `em` multiplies whatever the page inherits, and a theme or kit that sets a large base silently scales your whole component: `font-size: 0.95em` intended as ~15px computed to **22px** because the page's base was 23px. A panel or dialog should set its own base (`font-size: 0.95rem`) and size its children in `rem` too, so it is predictable on any theme while still following the reader's root size. - **A `::before` scrim on an element that establishes a stacking context paints OVER it, not behind.** Within a stacking context the paint order is: the element's own background and borders FIRST, then negative-`z-index` children. So a popup styled `.panel { position: fixed; z-index: 10; transform: … }` with `.panel::before { position: fixed; inset: 0; z-index: -1; background: rgba(0,0,0,.45) }` tints **the panel itself** — the card renders grey with washed-out controls and it looks like a colour bug, not a layering one. A backdrop must be a **sibling element** one layer below, never a pseudo-element of the thing it is meant to sit behind. - **`overflow: hidden` on a card clips any popover you position inside it.** A hero/card that clips its image to a rounded corner also clips an absolutely-positioned dropdown, share panel or tooltip rendered within it — measured: a 164px panel inside a 229px hero, cut off at the card's edge. `position: fixed` escapes an ancestor's overflow, but **only if no ancestor establishes a containing block**: any `transform`, `filter`, `perspective`, `backdrop-filter`, `will-change: transform` or `contain: paint` on the way up re-anchors the fixed element and the clipping returns. Walk the ancestor chain and check for those before relying on it. - **`container-type: inline-size` can inflate the widget wrapper to tens of thousands of pixels.** Inside Elementor's nested flex containers (`.e-con` → `.e-con-inner` → `.elementor-element`), Chromium computed the widget wrapper at **62,926px against 4,867px of real content** — ~58,000px of blank page below the widget — but **only at narrow viewports**; desktop was exact. The giveaway is a parent taller than its only child. Bisect by setting `container-type: normal` inline: if the height collapses, that is it. Nothing else fixed it — not `min-height: 0`, `display: flow-root`, `contain-intrinsic-block-size`, `align-self: stretch`, nor removing the flex context from the wrapper — and neither the `@container` rules nor the `cqi` units were involved (neutralising both changed nothing). Unless you genuinely need the widget to respond to *its own* width rather than the viewport's, prefer a plain `@media` query at Elementor's breakpoint (767px mobile / 1024px tablet) and a `vw`-based `clamp()` instead of `cqi`. - **Guard independent registrations independently.** A CPT-registration function that bails with `if ( post_type_exists( $slug ) ) { return; }` — so a page-builder plugin like CPT UI can stay the owner — will also skip every `register_taxonomy()` call after it. On every site where the other plugin owns the post type the taxonomy is then never registered at all, and each `wp_get_object_terms()` returns `WP_Error( 'invalid_taxonomy' )`. Devastating right after a migration that moved terms onto that taxonomy. Guard each registration with its own `post_type_exists()` / `taxonomy_exists()` check. - **Renaming a taxonomy is one column.** `UPDATE wp_term_taxonomy SET taxonomy = 'new' WHERE taxonomy = 'old'` is the whole migration: `wp_term_relationships` points at `term_taxonomy_id`, which does not change, so every object keeps exactly the terms it had. Follow with `clean_taxonomy_cache()` for both names (term caches key on the taxonomy name, so the old one lingers as a phantom) and a rewrite flush if the archive slug changed. - **`accent-color` on a range input is dead the moment a theme sets `appearance: none`.** It only tints *native* track/thumb rendering, so a control that writes `accent-color` is inert and the slider renders as a bare rectangle. Style the parts explicitly and element-qualified: `::-webkit-slider-runnable-track` + `::-webkit-slider-thumb` (needs its own `-webkit-appearance:none` and a `margin-top` to re-centre it on a thin track) and `::-moz-range-track` / `::-moz-range-progress` / `::-moz-range-thumb`. WebKit has **no** `::-moz-range-progress` equivalent — draw the filled portion as a background sized from a `--pct` custom property the script updates on `input`. Keep the input itself at a 44px hit height with a transparent background and let the visible track be thin. --- ## 5. Embedding a standalone app as an Elementor widget - **Render inline — never in a cross-document `<iframe>`.** Elementor's style controls inject CSS onto the **parent** page; that CSS **cannot cross into a cross-document iframe**, so "controls on the toolbar style the app" is impossible with an iframe. Iframe embedding is a dead end for anything you want Elementor-styleable. - **Inline means the theme's and Elementor's CSS bleed IN, not just out.** Elementor's frontend rules (`.elementor img { height:auto }`) and the active theme **out-specify** your single-class selectors → giant logos, broken layout. Boost specificity by **doubling your root class** (`.myapp.myapp .thing`, specificity (0,2,…) beats `.elementor .thing`), add defensive resets, and scope variable-setting (theme) controls to `{{WRAPPER}} .myapp-scope`. - **Per-element control selectors must out-specify your boosted base.** If base rules are `.myapp.myapp .x` (0,3,0), a normal `{{WRAPPER}} .x` control selector (0,2,0) **loses** and the control does nothing — target `{{WRAPPER}} .myapp.myapp .x`. Engine-rendered **inline** `style="color:…"` can't be overridden by any control — don't expose controls for those values. - **`container-type: inline-size` + a non-stretch flex parent collapses to a sliver.** A size-containment element has zero intrinsic width; an `align-items:center` parent shrink-wraps it to nothing. Default widget wrappers to `width:100%` and centre via a Max-width control, not the container's alignment. Use container units (`cqw`) instead of `vw` so the widget responds to its own width, not the viewport. - **A blanket `button {}` reset breaks native buttons and lets the theme bleed through.** Keep `text-align:center; line-height:normal` in the reset and harden specific buttons against theme rules (e.g. Astra's `button { background; color; padding }`). --- ## 6. PHP & security gotchas - **A string baked into a config array at registration time is frozen — `apply_filters()` inside it fires before anything can hook it.** The classic shape is a field/section definition built on `init`: ```php // ✗ the filter runs NOW, during registration — nothing is hooked yet $form->add_field( 'card_open', [ 'type' => 'html', 'html' => '<h3>' . esc_html( apply_filters( 'myplugin_card_title', __( 'Profile', 'myplugin' ) ) ) . '</h3>', ] ); ``` Registration runs on `init`; an Elementor widget's `render()`, a shortcode, or a template part runs **much later**. Any `add_filter()` those add is registered *after* the value was already computed, so the override is silently ignored — the filter "exists", fires, and does nothing. The same freeze hits `__()`: the string is translated once, in whatever locale was active at registration. **Fix — defer the value, not the filter.** Store a callable and resolve it where the value is actually used: ```php // ✓ resolved at render time, so later-registered filters apply $form->add_field( 'card_open', [ 'type' => 'html', 'html' => static function (): string { return '<h3>' . esc_html( apply_filters( 'myplugin_card_title', __( 'Profile', 'myplugin' ) ) ) . '</h3>'; }, ] ); // …and in the renderer: $html = $field['html'] ?? ''; if ( $html instanceof Closure ) { $html = $html(); } echo wp_kses_post( (string) $html ); ``` Symptom to recognise: *"my filter/override works for some strings on the page but not others."* The ones that work are echoed at render time; the ones that don't were baked at registration. Code review will not catch this — only rendering it will. - **Read cookies from `$_COOKIE`, not `$_REQUEST`.** PHP's default `request_order = "GP"` means `$_REQUEST` holds GET+POST only — **never cookies**. A cookie read through a `$_REQUEST`-based helper always comes back empty (e.g. "new device" every login). Read `$_COOKIE['name']` directly, then `wp_unslash()` + validate. - **Secrets are encrypted at rest and never re-rendered in admin HTML.** Don't store an API client secret as a plaintext option and echo it back into `<input value="…">` — DB-level tools read it in full. Encrypt at rest (e.g. AES-256-GCM keyed from wp-config salts), render an **empty** field with a "leave blank to keep" sanitizer, and never assume a third-party "redaction" layer knows your option names — verify empirically. - **Rate-limit on `REMOTE_ADDR` by default.** Forwarded headers (`X-Forwarded-For`, etc.) are trivially spoofable; trusting them by default lets an attacker rotate fake IPs past the limiter. Make forwarded-header trust **opt-in** via a filter, for sites genuinely behind a known proxy. - **Policy decisions belong on the core filter, not one handler.** A subscriber/redirect rule enforced only inside your own login handler is bypassed by `wp-login.php` and third-party login flows. Put it on the core filter (`login_redirect`, high priority) so it applies everywhere. - **Don't `wp_kses()` inline SVG — it lowercases `viewBox` and breaks the icon.** `wp_kses_hair()` lowercases every attribute name, but SVG attributes are **case-sensitive** (`viewBox` → `viewbox` is ignored → the icon clips). For static developer SVG, inline it as a **string literal** (no variable) so Plugin Check sees safe literal output; for SVG that must come from a function in `render()`, a justified `// phpcs:ignore … -- static inline SVG, no user input` is acceptable. - **`sanitize_text_field()` always returns a string** (even `''` for array/object input). A following `is_string()` guard is dead code (PHPStan `function.alreadyNarrowedType`) — drop it. - **Security headers need a send-once guard — and three hooks.** Other plugins send the same headers (X-Frame-Options, nosniff…); check `headers_list()` before sending so you never duplicate one already queued. Send on `send_headers` (front end) **plus** `admin_init` and `login_init` — `send_headers` alone never covers wp-admin or the login screen. Exception: a **strict** `Cache-Control: no-store, private` must overwrite whatever weaker value a cache/theme queued earlier — don't send-once-guard that one. - **Never build a regex request-filter "WAF"** that pattern-matches SQLi/XSS/XXE payloads — bypass-prone, false-positive-prone, breaks sites, and wp.org reviewers reject it. A platform plugin defends **transport + configuration**; it cannot patch other code's bugs — document what's out of scope instead of shipping snake oil. - **CSP on WordPress: only the nonce-free directives.** `object-src 'none'`, `base-uri 'self'` and `frame-ancestors` are deliverable; a real `script-src` policy is impractical on WordPress (inline scripts everywhere) — don't attempt it. Assemble the entire policy into **ONE** `Content-Security-Policy` header; never emit two. - **Web-cache deception has a buildable defense:** send `Cache-Control: no-store, private` on (a) logged-in responses, (b) dynamic responses served under a static-looking extension (`/account.css`), and (c) REST auth responses — plus define the cache-plugin bypass constants (`DONOTCACHEPAGE` etc.). A broken logged-in cache bypass really does serve guest-cached HTML to authenticated users in production. - **Secret redaction lives in ONE shared helper used by every output path.** Redacting an option name in one tool is pointless while another exit (a DB reader, an options dump, an export) can read the same value — route every endpoint through the same `is_secret_name()` / redaction functions. - **One-time notices: transient flash + PRG, never a URL flag.** A "saved!" notice read from `$_GET` survives every refresh. Set a short **user-scoped transient**, redirect to a clean URL (Post/Redirect/Get), then read-and-`delete_transient()` on display — it shows once, never again. ### Holding a login (2FA, device confirmation, terms acceptance) is not a failed login - **Returning a `WP_Error` from `authenticate` makes core fire `wp_login_failed`.** `wp_authenticate()` ends with an allowlist of exactly two codes — `array( 'empty_username', 'empty_password' )` — and fires the hook for **every** other error code. So the moment you hold a login for a second step, everything that listens for brute force counts a correct password as a failure: your own activity log, your own per-account throttle, and every security plugin on the site. `get_error_code()` is consulted, i.e. the **first** code only. - **The symptom is a slow poison, not a bug report.** The user signs in fine, so nothing looks broken; the login-activity dashboard just fills with failures for people who did nothing wrong, and a progressive throttle starts adding seconds after the free allowance (three, typically) is spent on successful sign-ins. It surfaces as *"why does my dashboard say I keep failing?"* weeks later. - **Fix by discriminating, not by silencing.** `wp_login_failed` passes the `WP_Error` as its **second** argument (core has done so since 5.4), but listeners registered with `accepted_args` of 1 never see it. Register yours with 2 and skip your own hand-off code: ```php add_action( 'wp_login_failed', 'prefix_record_failed', 10, 2 ); function prefix_record_failed( $username, $error = null ): void { if ( $error instanceof WP_Error && in_array( 'prefix_2fa_required', $error->get_error_codes(), true ) ) { return; // a correct password waiting on its second step } … } ``` - **Then count the step that really does fail.** Having excluded the hand-off, the *wrong* second factor is usually recorded nowhere — the dashboard ends up exactly backwards. Fire `wp_login_failed` yourself from the verification handler with your own error code (the Two-Factor feature plugin does the same), so the log, the throttle and third-party brute-force plugins all see the real attempt. - **Don't `exit` from the filter to dodge the hook.** Redirecting out of `authenticate` skips every filter registered at a later priority, including other plugins' post-password checks — you would be trading a miscount for a security hole. Return the `WP_Error` and discriminate. - **Audit every path, not the one you were shown.** A plugin that intercepts both its own form and `wp-login.php` usually handles them differently (one returns the error, one redirects), so only one path miscounts — and the two disagree about what happened until you fix it. --- ## 7. Transactional email (`wp_mail`) - **Email bodies are inline styles ONLY.** `wp_kses_post()` does not allow `<style>`/`<head>`, so it strips those **tags** but **keeps their text content**; `wpautop()` then wraps the leftover CSS in `<p>` and the mail client renders it as **visible body text**. WordPress's mailer also treats the body as an HTML **fragment** (`<html>`/`<head>`/`<style>` are not honoured, and email clients ignore `<style>` blocks anyway). **Strip `<style>/<head>/<script>/<link>` blocks (content included) and `<html>/<body>` wrappers BEFORE `wp_kses_post()`,** then style every element with `style="…"` attributes. Set `Content-Type: text/html` only on the custom-body path. --- ## 8. Theme Builder & editor context - **Theme Builder targets a REAL page, not a virtual post.** Plugins that serve content from a virtual post (`ID = -1`) never match Elementor Theme Builder conditions (Singular > Page) — no header/footer/template applies and you get a bare unstyled page. Create **real WP pages** for each action and let Elementor target them by ID; keep virtual rewrite pages only as the non-Elementor fallback. Create/adopt those pages idempotently on activation (see §9). - **Detect editor/preview context** when your code rewrites URLs or changes behaviour, so it stands aside in the builder. Check: `REST_REQUEST` + an `/elementor/` route, `?action=elementor`, `?elementor-preview`, `elementor_*` AJAX actions, and `\Elementor\Plugin::$instance->preview->is_preview_mode()`. Enqueue editor-only CSS on `elementor/editor/after_enqueue_styles`. - **Refreshing `--var` defaults won't show on already-customised widgets, and Elementor caches per-page CSS.** A new default only appears where the user hasn't set an Elementor value, and not until **Elementor → Tools → Regenerate Files & Data** + a hard refresh. Tell the user this instead of assuming the change is broken. - **The document a widget lives in is NOT the post being viewed — never use `get_queried_object_id()` to identify your own widget.** On a Theme Builder template the settings are in the template's `_elementor_data`; the queried object is whichever post the template is currently rendering (and on an archive it isn't a post at all). Any code that looks the widget up by the queried ID — an AJAX endpoint, a REST route, a cache key — finds nothing and fails on **every** theme template while working perfectly on ordinary pages. Ask Elementor instead, inside `render()`: ```php $doc = \Elementor\Plugin::$instance->documents->get_current(); $id = $doc ? (int) $doc->get_main_id() : (int) get_the_ID(); ``` `Frontend::get_builder_content()` calls `switch_to_document()` before rendering elements for exactly this reason. Use `get_main_id()`, not `get_id()`, so revisions and autosaves resolve to the parent. --- ## 9. Data safety — activation & uninstall - **Uninstall must not delete the user's content.** Only delete pages/posts your plugin created (flagged with your own `_myplugin_auto_created` meta), that are **unedited** (no `_elementor_edit_mode`), and **empty**. Deleting "by stored ID" without provenance checks wipes pages the user later customised. - **Activation is adopt-or-create, idempotently.** Never duplicate an existing plugin page, never overwrite user edits. Re-running activation (or an update) must converge, not multiply. - **Rewrite-rule changes need a permalink flush** — a new action slug 404s until rules regenerate. Flush on activation, and on first load after an update; don't rely on the user visiting Settings → Permalinks. - **Managed marker blocks (`# BEGIN MyPlugin` … `# END MyPlugin`) need line-anchored strip regexes.** If one marker name is a string **prefix** of another ("MyPlugin" vs "MyPlugin Hardening"), an unanchored regex matches the wrong block and swallows content between them. Anchor the marker lines (`[ \t]*\R` after BEGIN, `[ \t]*(?:\R|$)` after END) — and remove EVERY block you wrote on deactivation, not just the first. --- ## 10. Build, versioning & wp.org distribution - **`languages/` ships the `.pot` and nothing else.** Compiled `.mo` / `.l10n.php` files inside a wp.org-hosted plugin never load (the loader only scans `WP_LANG_DIR/plugins/`) and the call that would load them is flagged by Plugin Check. Keep author-side catalogues **outside** the plugin folder — e.g. a repo-level `translations/` dir excluded from the zip allowlist. Full mechanism and the translate.wordpress.org workflow: `wordpress-apis.md` §6. - **A "sync to test install" step that reports success can copy nothing.** On Windows, calling `robocopy` from a POSIX shell with **forward-slash paths** makes it read `/Users/...` as switches: it prints its usage text, copies zero files, and **still exits 0**. Everything downstream then tests stale code while every check passes. Use `cp -r` from that shell (or backslash paths from PowerShell), and afterwards **assert a known-new symbol exists in the destination** rather than trusting the exit code. - **Splitting public/private repos: don't let a "sync" overwrite the public page.** A common layout is a private source repo plus a public docs/Pages repo. The published `index.html` usually carries things the private copy lacks — canonical/OpenGraph tags, and a download button pointing at **wordpress.org** rather than a GitHub archive. Port the *content* change (version string, "What's New") into the public copy; never overwrite it wholesale, or you ship a broken download link. Untrack the duplicate in the private repo so there is one source of truth. - **Release order:** publish to SVN first, confirm the directory serves the new version, *then* update the public page — so the site never advertises a version users cannot download. - **The distributable zip is an allowlist, not the repo.** Copy ONLY runtime dirs/files (`includes/ admin/ assets/ languages/` + `readme.txt uninstall.php <main>.php`) into a clean staging folder named exactly as the slug, then compress. Everything else (`.git/ .github/ CLAUDE.md README.md *.html graphify-out/ wporg-assets/ Screenshots/ dotfiles`) must stay out, or Plugin Check flags stray/hidden/bad-name files. Verify the staged tree has no dotfiles before zipping. - **A version bump touches every surface in lockstep:** main-file header `Version:` + the `MYPLUGIN_VERSION` define; `readme.txt` `Stable tag` + `== Changelog ==` + `== Upgrade Notice ==` (each notice < 300 chars); `README.md` changelog; and any landing `index.html` download button/title. After bumping, **grep all files for the old version** — a stuck `index.html` is easy to miss. - **Only bump the version when front-end assets change.** A server-only fix (data/answer-key, back-end logic) needs no bump — bumping forces every visitor to re-download all assets for nothing. Conversely, CSS/JS changes are **invisible without** a version bump (it is the enqueue cache-buster). - **Every stylesheet edit must hit BOTH `.css` and `.min.css`.** Production enqueues the minified file (the unminified one only under `SCRIPT_DEBUG`), so a change applied only to `.css` ships invisible. After editing, grep **both** files for the new selector, then bump the version constant. - **Verify the zip's internal path separators are FORWARD slashes.** Windows PowerShell 5.1's `Compress-Archive` writes **backslash** entry names (`slug\main.php`); Linux treats `\` as an ordinary filename character, so WordPress extracts flat files with no plugin folder → **"plugin does not exist"** on upload. Pack with a .NET 5+ tool (pwsh 7+) and, after packing, assert an entry named `slug/main-file.php` (forward slash) exists. On a cloud-synced folder (OneDrive/Dropbox) a just-written zip can briefly vanish mid-sync — re-create and re-verify if it disappears. - **Screenshots, banner, and icon are wp.org SVN `/assets/` files — NOT part of the plugin zip.** Name screenshots `screenshot-1.png`, `screenshot-2.png`, … each with a matching caption in `readme.txt == Screenshots ==`. Banner (`banner-772x250` / `banner-1544x500`) and icon (`icon-128x128` / `icon-256x256`) are listing graphics, also assets-only. Re-check listing graphics after any **rename** — an old-brand banner survives unnoticed across releases. - **Run Plugin Check (the wp.org reviewer's own tool) before every resubmission**, excluding any vendored-lib directory, until **your** code is 0 findings. - **The directory runs on SVN, not git.** The release itself — checkout, copy the built tree into `trunk/`, `svn cp trunk tags/X.Y.Z`, commit, and manage `/assets/` — is a Subversion workflow. Full commands + a WordPress.org worked example are in the **`svn/`** sub-bundle (`svn/svn.md`). --- ## 11. Verification reality - **A scripted patch anchored on a bare `function foo(` signature STEALS that function's docblock.** Inserting new code immediately before a function signature puts the new function *between* the existing docblock and the function it documents: the old function is left undocumented and the new one wears two docblocks stacked. `php -l` passes, the code runs, and every structural grep is happy — only PHPCS (`Squiz.Commenting.FunctionComment.Missing`) sees it. It happened three times in one change because the same anchoring style was reused. Anchor on the **docblock opener** (`/**\n * The thing.`) rather than the signature, or assert afterwards that the file contains no `*/` immediately followed by `/**`. - **A `case` block that references the wrong variable fails SILENTLY and shaped like success.** `substr( $key, … )` inside a function whose parameter is `$field` yields `''`, so an array lookup built from it returns `''` and every placeholder rendered empty — a warning in the log and nothing visibly broken. `php -l`, a call-type checker and every "is the case block present?" grep all pass, because the block *is* present. Only rendering the markup with `display_errors` on showed it. Assert on the **resolved value** (`attrs['placeholder'] === $expected`), never on the presence of the code that computes it. - **`php -l` / `node --check` prove syntax only.** They do **not** catch typed-signature mismatches (a fatal — see §1), missing/ineffective controls, leaked filters, or any runtime behaviour. Stub-test against a **typed `\Elementor\Widget_Base`** (instantiate + run `register_controls()` via reflection + exercise `render()` for each user state) before shipping. - **You usually cannot see visual bugs from static checks.** Logo size, button placement, a sliver-collapse, a colliding flex column, a `var()` that resolved to nothing — all pass lint and "look fine" in code. Verify on a **live render** (the WP MCP bridge / a browser-driven preview), inspecting **computed styles**, not just screenshots. - **A `<button>` inside your widget is a theme's button.** Themes style bare `button:hover` / `button:focus` with the site accent (Astra does, at **0-1-1**), which outranks your single class — so the one control in a row that happens to be a button flips colour on hover and then *stays* that colour on focus after it is clicked, while the labels and links beside it do not. Elementor control CSS does not save you: it only ever targets the resting state. Fix on both sides — put `:hover, :focus` into the control's own `selectors` so a chosen colour reaches those states, and restore the resting look in your stylesheet at a specificity above the theme's. - **`display: var(--display)` with the variable unset computes to `inline`, not `block`.** An Elementor container whose generated CSS did not load loses its `--display`, the declaration is invalid at computed-value time, and `display` falls back to its *initial* value. The container then generates a line box, so it looks like "a small mystery gap above my first section" rather than a stylesheet that failed to load. Check `getComputedStyle(el).getPropertyValue('--display')` before chasing the spacing. - **PHPStan on an Elementor plugin needs two config lines, or it argues for deleting your guards.** Point `scanDirectories` at the real Elementor source (a hand-written stub cannot catch signature drift), then: - add a `bootstrapFiles` entry `define()`-ing your plugin constants — PHPStan does not evaluate the `define()` calls in your bootstrap, so every use reads as `constant.notFound` and buries the real findings (14 of 33 on one run); - set **`treatPhpDocTypesAsCertain: false`**. Elementor declares its managers as *untyped* properties carrying only a `@var` docblock (`public $preview;`) and they are genuinely null until it initialises them. Left on, PHPStan calls every defensive `isset()` redundant and every version_compare against the scanned Elementor "always false" — i.e. it recommends removing the exact checks that prevent a fatal. What survives that is worth reading. **Run level 8 once even if you gate at 5**: past the `missingType.iterableValue` noise it found `foreach ( glob( … ) )` — `glob()` returns `false` on failure — and a sanitiser returning `preg_replace()`'s result, which is `null` when the engine gives up. - **Plugin Check warnings are not errors, and some are the sniff reading an array key.** `'meta_value' => __( 'Custom field' )` in a SELECT control's options trips `SlowDBQuery.slow_db_query_meta_value`; `'exclude' => __( 'Exclude sticky posts' )` trips `PostNotIn_exclude`. Neither is a query argument. Those earn a tagged `phpcs:ignore`; a real `post__not_in` excluding a bounded, user-chosen set does not — leave it and say why. - **wp.org screenshots can be produced headlessly, including the editor panels.** `chrome --headless --screenshot` only loads a URL and snaps it, which is enough for the front end but cannot select a widget. Drive Chrome over the DevTools Protocol instead — Node 21+ has a global `WebSocket` and `fetch`, so it needs no packages: launch with `--remote-debugging-port`, read `/json` for the target, then `Page.navigate` / `Runtime.evaluate` / `Page.captureScreenshot`. Four things bite, in order: - **`--screenshot` needs an ABSOLUTE path.** A relative one writes nowhere and reports nothing. - **The editor is behind `auth_redirect()`**, which reads `$_COOKIE` directly. A shim that calls `wp_set_auth_cookie()` and lets the same request continue does nothing; it must set the cookie and then **redirect**, so the cookie is present on the next request. No password is typed. - **Headless loses the preview-iframe load race**, leaving the editor on its splash with everything underneath already loaded. Call `elementor.onPreviewLoaded()` yourself, hide `#elementor-loading`, and force `#elementor-preview` visible. - **Select the widget with `$e.run('document/elements/select', { container })`** rather than a synthetic click, then wait ~10s — selecting re-renders the preview, and capturing too early gives an empty canvas. Cards below the first row are lazy-loaded and a screenshot cannot scroll, so a demo-only filter swapping `loading="lazy"` for `eager` is what makes lower rows appear. - **Never assert "my query matches the archive" against `$wp_query->posts`.** That is the archive's *first page*, so a widget asked for 100 per page legitimately returns more and the assertion fails on correct code — which then sends you refactoring a query that was fine. Compare against `found_posts`, or re-run the archive's own `query_vars` unpaged. - **Style-tab controls do not exist outside the editor unless you ask for them.** Elementor skips registering them for performance, so a harness that inspects `get_controls()` from WP-CLI reports **every Typography / Colour / Border / Box-Shadow / alignment control as missing** — a wall of confident false failures against code that is completely fine. Call `\Elementor\Core\Frontend\Performance::set_use_style_controls( true )` before `get_controls()` (this is exactly what `Core\Files\CSS\Base::parse_content()` does before rendering CSS). - **Group controls do not carry their selector on the control you would guess.** The `_typography_typography` and `_box_shadow_box_shadow_type` entries are the *type* switches and have no `selector`; the real one lives on `_box_shadow`, `_font_family`, `_font_size` and so on. Assert against a sub-control that actually has a selector, or the assertion is meaningless. - **When a check fails, suspect the check before the artifact.** Mis-scoped regex, byte-slicing multibyte UTF-8 (`head -c`/`tail -c` cuts a Georgian/emoji char mid-byte → looks like corruption), or a stub that flags on the wrong condition produce confident **false alarms**. Verify the harness (paths, encodings, regex), then the file. - **WP-CLI cannot test code that stands down under WP-CLI — and it fails *green*.** Auth and security guards routinely open with a bail for non-interactive contexts: `REST_REQUEST`, `XMLRPC_REQUEST`, `application_password_did_authenticate`, `wp_doing_cron()`, `defined( 'WP_CLI' ) && WP_CLI`. Correct code — none of those has a second factor — but it means the branch never executes under `wp eval-file`, and every assertion of the form *"X did not happen"* passes having exercised nothing. Such a test passes identically with the fix reverted. Before writing one, grep the function under test for those constants; if any appears, drive it over HTTP instead: a probe in the webroot that does `require __DIR__ . '/wp-load.php'`, fetched with `curl` and deleted afterwards. Print `defined( 'WP_CLI' ) ? 'yes' : 'no'` in its output so the transcript proves which context ran, set `$_SERVER['REQUEST_METHOD'] = 'POST'` and populate `$_POST` to stand in for a form submission — and pair every "did not happen" with a control that **does** (a wrong password that still logs and still counts). - **Run Plugin Check against what ships, not your working tree.** Copying the dev tree into a local WP makes it report `hidden_files` / `application_detected` / `unexpected_markdown_file` for `.gitignore`, `phpunit.xml.dist`, `CLAUDE.md` and friends — files `export-ignore` and `.gitignore` keep out of the package anyway. Those findings are noise that hides the real ones. Deploy only the shipped set (take it from the SVN trunk listing, don't retype it), empty the destination first, and assert nothing extra arrived. --- ## 12. Re-rendering a widget out of band (AJAX / REST) and in loops ### Before building an AJAX endpoint: consider re-rendering the real page and swapping a fragment An admin-ajax or REST handler that returns HTML is a **second renderer**, and it runs without the page that made it. For a panel inside a widget that is the wrong trade, because the widget's own settings are exactly what is missing: - **Editor text overrides are invisible to a background handler.** A widget that applies its Content-tab overrides by adding filters around its own `render()` (add → render → remove) has none of them active inside admin-ajax: there is no widget instance and no settings. The endpoint returns the *translated default* instead of the site owner's wording, and the panel silently reverts to English the first time the user touches it. The same applies to anything else resolved at render time — conditional enqueues, `{{WRAPPER}}` CSS, `the_content` filters, locale. - **The alternative: post the form to the page's own URL and swap one element.** Mark the request (`prefix_fragment=1`), have the handler do its work and **return instead of redirecting**, let WordPress render the page exactly as it always does, then client-side parse the response and replace one container's `innerHTML`: ```js const res = await fetch( pageUrl, { method: 'POST', body: fd, credentials: 'same-origin' } ); const doc = new DOMParser().parseFromString( await res.text(), 'text/html' ); target.innerHTML = doc.querySelector( '.prefix-panel-body' ).innerHTML; ``` There is no second renderer, so the swapped markup **cannot** drift from what a reload produces. - **Cost is one extra page render** — for a logged-in account page that is nothing, and it buys every filter, override and translation for free. Weigh it against maintaining a parallel HTML builder forever. - **Carry the state flag in the request, not the URL.** The PRG path signals results with a query arg (`?prefix_codes=1`); with the redirect skipped there is no query string. Give the renderers one lookup that falls back to `$_GET`, so both paths read the same way: ```php function prefix_flash( string $key, ?bool $set = null ): bool { static $flags = []; if ( null !== $set ) { return $flags[ $key ] = $set; } return $flags[ $key ] ?? ( '' !== (string) prefix_get( $key, 'get' ) ); } ``` - **A helper that used to `exit` now returns — add `return;` at every call site.** PRG helpers are written as `prefix_redirect( … );` with no `return` after them because they never came back. Make one return and the code below it runs: the "invalid, bounce back" branch falls through into the success branch. Grep every call, not just the ones you edited. - **Failure handling is asymmetric, and getting it wrong repeats the action.** If the request never completed the server did nothing — fall back to `form.submit()`. Once a response has arrived the server *has* acted, so a later error must `location.reload()`, never re-post; a second submit regenerates recovery codes, sends a second email, charges twice. Set a flag the moment the response lands and branch on it in `catch`. - **Bind the submit handler on the container, delegated**, so it survives the swap; clear any "already bound" marker you keep on that container, and re-run whatever initialised the old markup (QR drawing, copy buttons). Guard re-entry with a `data-busy` attribute — a disabled button is not enough when Enter also submits. - **Progressive enhancement is the whole point:** the same form still posts normally with no JavaScript, and both paths must be tested. `curl` the form without the marker (expect `302` + the flag in `Location`) and with it (expect `200` and the new content in the body). - **`$_SERVER['REQUEST_METHOD']`-gate the marker.** A forged `GET ?prefix_fragment=1` must not reach a state-changing handler. - **A background request has no page.** `get_the_ID()`, `$wp_query`, `is_singular()` and the permalink of "the current page" are all absent inside a REST or admin-ajax handler. Any query option built on them — *related to the current post*, *exclude the current post*, *current query* — silently produces a **different result set** on page 2 than on page 1, so the visitor gets repeats or gaps rather than an error. Pass the displayed post's ID explicitly, validate it (`get_post()` + published + not password-protected), `setup_postdata()` around the render, and restore afterwards. - **Don't try to rebuild an archive's main query in a background request.** Query vars sent by the client are the caller defining their own query, which is the thing your endpoint exists to prevent; `WP::parse_request()` a second time fires `parse_request` again and other plugins hooked there will act on it. For a *current query* source, leave pagination as **real page links** — that is what Elementor's own Archive Posts widget does — and say so in a control description. - **Links rendered inside an AJAX response are built from the AJAX request.** `add_query_arg()` and `remove_query_arg()` with no URL argument read `$_SERVER['REQUEST_URI']`, which during the call is `/wp-json/...`. Middle-clicking such a link hands the visitor raw JSON. Send the page's URL along and validate it with **`wp_validate_redirect( $url, '' )`** — core's own "is this URL ours" check — rather than trusting it or inventing a regex. - **"No results" and "past the end" are different answers.** Rendering the empty state for any page with no posts means an append-mode request one page past the end drops *"no posts found"* underneath a grid full of results. Gate the empty state on page 1. - **A Load More button must stop existing, not just stop working.** Guard on `$paged >= $max_num_pages` and render nothing — a button that survives its last page reads as a stray line of unstyled text under the grid, and pressing it fetches an empty page. ### Dynamic-tag values that render as CSS cannot be looped - **Elementor deliberately keeps them out of the cached stylesheet.** A control that is both dynamic and CSS-producing — a container **background image** bound to the featured image is the usual case — is recorded in the CSS file's `dynamic_elements_ids` meta and *excluded* from `elementor-post-<id>.css`. It is supplied separately by `\Elementor\Core\DynamicTags\Dynamic_CSS`, resolved against whatever post is current. - **That mechanism is once-per-request and single-selector, so it cannot serve a loop.** `Core\Files\CSS\Base::enqueue()` records the file handle in a **static** `$printed` array and returns early ever after; and the selector it emits (`.elementor-<template> .elementor-element-<id>`) is identical for every card, so even emitted N times the last post would win for all N. The symptom is exact and misleading: **the image shows in the editor and is simply absent on the front end**, because the editor regenerates CSS against the current post on every render. - **Fix by scoping per item.** Give each card wrapper a per-post class, subclass `Dynamic_CSS`, override `get_element_unique_selector()` to prefix that class (it is what fills `{{WRAPPER}}`, so it covers every control), construct it with `new` (not `create()` — the files manager caches by class+args and would hand every card the first card's CSS), and `echo` the result inline. Elementor Pro's loop grid does the same thing with `.e-loop-item-<post_id>`. - **Check `dynamic_elements_ids` is non-empty before doing any of this** — almost no template styles anything dynamically, and that check is what stops every card paying for a second CSS parse. - **An image *widget* with a dynamic tag has none of these problems**, because it renders an `<img>` inline per post. If a user only needs a per-post picture, that is the cheap answer. ### Swiper inside an optimised site - **Swiper's autoplay refuses to start while the carousel measures zero, and never retries.** `run()` opens with `if ( ! swiper.size ) { autoplay.running = false; autoplay.paused = false; return; }`. Nothing calls it again, so autoplay is dead for the life of the page. The fingerprint is `running === false` **and** `paused === false` — no other path clears both. - **This is why "it works in the editor and is frozen on the live site" happens.** LiteSpeed / WP Rocket / Cloudflare APO defer or async the stylesheet that gives the container its width, so the measurement at init is 0; the editor has no such optimisation. It is a **race**, so it reproduces intermittently — do not conclude it is fixed because one reload autoplayed. - **Re-arm rather than re-init.** Watch the root with a `ResizeObserver` (plus a short interval fallback, since a late stylesheet can change the width without a resize entry for that element); once `swiper.size` is non-zero, `swiper.update()` then `swiper.autoplay.start()`. Record any visitor-initiated pause on the instance first, so recovery never restarts what someone stopped. - **Elementor's carousels ship no visible play/pause button.** That is a WCAG 2.2.2 failure, not a pattern to copy. Keep the control reachable by keyboard and hidden until focused — that gives the same clean look without removing the mechanism. -
js-css-standards.md 8.6 KB
# JavaScript & CSS Standards ## JavaScript Standards ```js // ✅ IIFE + ES6+ — no jQuery unless forced by a WP core dependency ( () => { 'use strict'; const init = ( scope = document ) => { scope.querySelectorAll( '.myplugin__trigger' ).forEach( el => { el.addEventListener( 'click', handleClick ); } ); }; const handleClick = ( e ) => { e.preventDefault(); const trigger = e.currentTarget; const target = document.getElementById( trigger.getAttribute( 'aria-controls' ) ); const isOpen = trigger.getAttribute( 'aria-expanded' ) === 'true'; trigger.setAttribute( 'aria-expanded', String( ! isOpen ) ); // ✅ CSS class controls visibility — NOT aria-hidden (see offcanvas-ui.md for full pattern) target?.classList.toggle( 'myplugin__panel--open', ! isOpen ); target?.classList.toggle( 'myplugin__panel--hidden', isOpen ); }; // ✅ Wait for 'elementor/frontend/init' before registering addAction. // DO NOT use if(window.elementorFrontend) — if elementorFrontend already // exists, all element_ready events have already fired and addAction silently // misses every widget already on the page. // // ⚠️ BIND WITH jQUERY — this is the bulletproof, version-agnostic pattern Elementor's own // docs lead with. Elementor fires 'elementor/frontend/init' through jQuery's event system // ( jQuery(window).trigger(...) ). jQuery-triggered CUSTOM events do NOT reach native // addEventListener handlers, so on Elementor < 3.5 a window.addEventListener listener // SILENTLY NEVER FIRES — handlers on AJAX-loaded widgets (popups, loop items, editor // re-renders) just die with no error. Elementor 3.5+ added dual-dispatch (jQuery + native), // so addEventListener also works on modern versions — but jQuery is the safe default and // costs nothing extra (jQuery is already a dependency of elementor-frontend). // Source: developers.elementor.com/native-js-events-in-elementor/ // // ⚠️ This script must NOT use strategy:'defer' if it relies on this event — a deferred // script may execute AFTER init fires (silent miss). Use in_footer:true WITHOUT defer, and // declare 'elementor-frontend' (and 'jquery') as dependencies. jQuery( window ).on( 'elementor/frontend/init', () => { window.elementorFrontend.hooks.addAction( 'frontend/element_ready/myplugin-widget.default', ( $scope ) => { // ✅ Guard against undefined scope — can happen during lazy-loaded section reveal if ( ! $scope || ! $scope[0] ) return; init( $scope[0] ); } ); } ); } )(); ``` --- ## Enqueue API — WP 6.3+ defer/async ```php add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_assets' ); function myplugin_enqueue_assets(): void { wp_enqueue_style( 'myplugin-css', MYPLUGIN_URL . 'assets/css/myplugin.css', [], MYPLUGIN_VERSION ); wp_enqueue_script( 'myplugin-js', MYPLUGIN_URL . 'assets/js/myplugin.js', [], // no jQuery unless genuinely required MYPLUGIN_VERSION, // Both keys are required: 'strategy' controls HOW the script loads (defer/async); // 'in_footer' controls WHERE it is placed (head vs footer). They are independent. // Omitting 'in_footer' => true places the script in <head> even with defer. // See: make.wordpress.org/core/2023/07/14/registering-scripts-with-async-and-defer-attributes-in-wordpress-6-3/ [ 'strategy' => 'defer', 'in_footer' => true ] ); // ✅ Pass PHP data to JS — use wp_add_inline_script (best practice since WP 4.5+). // wp_localize_script() was designed for i18n string localisation only. // wp_add_inline_script() is the correct tool for passing arbitrary data to JS. // // ⚠️ wp_json_encode() returns false|string — guard against encoding failure. // "const myPluginData = ;" is a JS syntax error that breaks the whole page. $script_data = wp_json_encode( [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'myplugin_ajax' ), 'i18n' => [ 'error' => __( 'Something went wrong.', 'myplugin' ) ], ] ); if ( $script_data ) { wp_add_inline_script( 'myplugin-js', 'const myPluginData = ' . $script_data . ';', 'before' // inject BEFORE the script so it's available on load ); } } ``` --- ## CSS Standards (BEM + Scoped Design Tokens) ```css /* ─── BEM Naming ──────────────────────────────────────────── */ .myplugin {} /* Block */ .myplugin__header {} /* Element */ .myplugin__trigger {} .myplugin__trigger--active {} /* Modifier */ /* ─── Scoped design tokens — never pollute :root ─────────── */ .myplugin { --mp-color-primary: #1a1a2e; --mp-color-accent: #e94560; --mp-spacing-sm: 0.5rem; /* 8px */ --mp-spacing-md: 1rem; /* 16px */ --mp-spacing-lg: 2rem; /* 32px */ --mp-radius: 8px; --mp-transition: 0.2s ease; } /* ─── Fluid typography — replaces breakpoint font-size overrides ── */ .myplugin__heading { font-size: clamp(1.25rem, 2vw + 1rem, 2.5rem); line-height: 1.2; } /* ─── Accessible focus styles (never use outline: none without replacement) */ .myplugin__trigger:focus-visible { outline: 3px solid var(--mp-color-accent); outline-offset: 2px; } /* ─── Safe Elementor overrides — CRITICAL selector rules ───────────────── ⚠️ NEVER target .elementor-widget-container directly. This inner wrapper div is REMOVED when Elementor's "Optimized Markup" experiment is active (introduced 3.25 alpha; opt-in in 3.35.x; enabled by default for new sites in Elementor 4.0). Does NOT exist in V4 Atomic Elements at all. ✅ CORRECT patterns — always use the widget root or your own BEM classes: */ .elementor-widget-myplugin-widget { /* targets the widget root — always present */ } .myplugin-widget { /* your own BEM root class — always safe */ } /* ✅ {{WRAPPER}} in Elementor controls maps to .elementor-widget-myplugin-widget in CSS — use this for control selectors in register_controls(). */ /* ─── 8pt spacing system ───────────────────────────────────── */ .myplugin__card { padding: var(--mp-spacing-md); margin-block-end: var(--mp-spacing-lg); border-radius: var(--mp-radius); transition: transform var(--mp-transition); } ``` --- ## Non-Latin scripts — the typography rules that break silently A plugin in the wp.org directory gets translated into scripts you never test. Two Latin-only habits produce visibly broken text and never raise an error: **`text-transform: uppercase` + `letter-spacing` on UI micro-copy.** The small-caps look (`text-transform: uppercase; letter-spacing: .07em`) is a Latin convention. Georgian, Hebrew, Arabic, Thai and CJK are **caseless** — the transform is a no-op for them, but the tracking still applies and visibly pulls the word apart. Georgian `ან` ("or") on a form divider renders as loose, disconnected glyphs. ```css .myplugin__divider span { /* fine for Latin */ text-transform: uppercase; letter-spacing: .07em; } /* Caseless scripts gain nothing from the transform but still get the tracking — reset both. */ .myplugin__divider span:lang(ka), .myplugin__divider span:lang(he), .myplugin__divider span:lang(ar), .myplugin__divider span:lang(ja), .myplugin__divider span:lang(ko), .myplugin__divider span:lang(zh), .myplugin__divider span:lang(th) { text-transform: none; letter-spacing: normal; } ``` `:lang()` matches the document's `lang` attribute, which WordPress emits from the site locale via `language_attributes()` — so this needs no PHP. **Don't size containers to English string lengths.** Translations commonly run 20–40% longer (German, Georgian, Finnish) or much shorter (CJK). Fixed-width buttons, `white-space: nowrap` on labels, and single-line tab strips all fail on the first translated site. Prefer intrinsic sizing plus wrapping; if a value must not wrap, give it `min-width: 0` and `overflow-wrap: anywhere` so it shrinks and breaks rather than overflowing (see field-notes.md §4 on the flex `min-width` footgun). **Reminder:** if you ship any of this as a hardcoded value you have also broken Golden Rule #6 — alignment, tracking and transform should be Elementor controls, and then the user can fix it themselves in any language. -
mariadb.md 15.6 KB
# MariaDB / MySQL — the database layer for WordPress plugins > **When to read this file:** Creating or altering a **custom table**, writing **direct `$wpdb` > queries**, diagnosing a **slow query**, choosing **column types or indexes**, or hitting a > charset / collation / "Specified key was too long" error. Distilled from the official **MariaDB > Knowledge Base** (mariadb.com/kb) and the WordPress plugin handbook. > > **Scope:** WordPress runs on **both MariaDB and MySQL**, and a distributed plugin does not get to > choose which. Everything here is written for that reality — §6 lists the differences that > actually break portable SQL. For the `$wpdb->prepare()` security rules and the Plugin Check > sniffs that fire on direct queries, see **`php-standards.md`** and **`debugging.md`** §1; this > file is about *schema and performance*, not escaping. --- ## 1. Versions — what WordPress actually needs | | Recommended (wordpress.org/about/requirements) | Will still run | |---|---|---| | **MariaDB** | **10.11+** | 5.5.5+ (End of Life — a security liability) | | **MySQL** | **8.0+** | 5.5.5+ (End of Life) | - The recommended MariaDB floor was **raised from 10.6 to 10.11** — verify against the live page before quoting it; it moves. - **Never assume the server version.** Shared hosts run old builds. Check at runtime before using anything version-dependent: ```php global $wpdb; $version = $wpdb->db_version(); // e.g. "10.11.6" — normalised by WP $is_maria = false !== stripos( $wpdb->db_server_info(), 'mariadb' ); ``` - Declare nothing about the DB in your plugin header — there is no `Requires MySQL`. If you need a modern feature, **detect it and degrade**, don't document a requirement nobody reads. --- ## 2. Character sets & collations — and the 191-character rule **`utf8` is not UTF-8.** MariaDB has two implementations: | Charset | Bytes/char | Stores | |---|---|---| | `utf8mb3` (historically aliased as `utf8`) | 1–3 | Latin, European, Middle-Eastern scripts — **no supplementary characters** | | **`utf8mb4`** | 1–4 | **All of Unicode** — emoji, rare CJK, mathematical symbols | In MariaDB 10.6+, `utf8` remains an alias for **`utf8mb3`** by default (changeable via the `old_mode` system variable). So a column declared `utf8` **silently cannot store an emoji** — the classic "user pasted 🎉 and the row truncated or errored" bug. > ✅ **Never write a charset by hand. Use WordPress's:** > ```php > $charset_collate = $wpdb->get_charset_collate(); // e.g. "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci" > $sql = "CREATE TABLE {$table_name} ( … ) {$charset_collate};"; > ``` > It reads `DB_CHARSET` / `DB_COLLATE` from `wp-config.php` and matches the rest of the install — > which also keeps your table **JOIN-able** with core tables. Joining columns of *different* > collations triggers `Illegal mix of collations` at runtime, not at `CREATE TABLE` time. ### ⚠️ The 191-character rule — why WordPress writes `varchar(191)` InnoDB caps an **index key** by page size (official limits): | InnoDB page size | Max index key length | |---|---| | 4 KB | 768 bytes | | 8 KB | 1,536 bytes | | **16 KB (the default)** | **3,072 bytes** | Older row formats (`COMPACT` / `REDUNDANT`) cap a single column prefix at **767 bytes**. At 4 bytes per character under `utf8mb4`: ``` 767 ÷ 4 = 191.75 → 191 characters ``` That is the entire reason WordPress core declares indexed string columns as **`varchar(191)`** rather than 255. Follow it: ```sql -- ❌ errors on older row formats: "Specified key was too long; max key length is 767 bytes" email varchar(255) NOT NULL, KEY email (email) -- ✅ safe everywhere email varchar(191) NOT NULL, KEY email (email) -- ✅ or index only a prefix when you need the full column length url varchar(2048) NOT NULL, KEY url (url(191)) ``` Modern `DYNAMIC` row format on 16 KB pages allows 3,072 bytes, so 255 chars *usually* works — but "usually" is not a basis for a distributed plugin. **Index at 191, or index a prefix.** --- ## 3. Custom tables — `dbDelta()` and its unforgiving syntax Only create a custom table when post types + meta genuinely don't fit (high-volume logs, analytics, queues). You lose the REST API, revisions, caching and every core query helper. ```php function myplugin_install_table(): void { global $wpdb; $table_name = $wpdb->prefix . 'myplugin_events'; // ALWAYS use the prefix $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL DEFAULT 0, event_type varchar(191) NOT NULL DEFAULT '', payload longtext NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY user_id (user_id), KEY event_type_created (event_type, created_at) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); update_option( 'myplugin_db_version', MYPLUGIN_DB_VERSION ); } register_activation_hook( __FILE__, 'myplugin_install_table' ); ``` > 🔴 **`dbDelta()` parses your SQL with regexes — its formatting rules are absolute.** Break one and > it fails *silently* or re-runs `ALTER TABLE` on every load: > - **One field per line.** No exceptions. > - **Two spaces** after `PRIMARY KEY` — `PRIMARY KEY (id)`. This is the single most common bug. > - Use **`KEY`**, never `INDEX`. Include **at least one** `KEY`. > - `KEY` → one space → key name → space → `(column)`. > - **No backticks or apostrophes** around field names. > - Field **types lowercase**, SQL keywords **uppercase**. > - Every type that accepts a length **must state it** — `bigint(20)`, not `bigint`. **Schema upgrades:** `register_activation_hook()` does **not** fire on plugin update (since WP 3.1). Version-gate the migration on a normal request instead: ```php add_action( 'plugins_loaded', function (): void { if ( get_option( 'myplugin_db_version' ) !== MYPLUGIN_DB_VERSION ) { myplugin_install_table(); // dbDelta() diffs and ALTERs — safe to re-run } } ); ``` `dbDelta()` **adds** columns and indexes; it never drops them. Removing a column is a deliberate, hand-written `ALTER TABLE` guarded by a version check. > ⚠️ **`CREATE TABLE` is DDL — Plugin Check flags it** (`DirectDatabaseQuery.SchemaChange`, plus > `.DirectQuery` and `.NoCaching`). Use the **block form** `phpcs:disable` / `phpcs:enable` with a > truthful justification — see `debugging.md` §1, which lists every sniff a `$wpdb` call can fire. --- ## 4. Indexes — the 80% that matters | Type | Purpose | |---|---| | **PRIMARY KEY** | Unique + `NOT NULL`, one per table. InnoDB **appends the primary key to every secondary index**, so keep it narrow — an `AUTO_INCREMENT` integer, never a long string. | | **UNIQUE** | Enforces uniqueness. Allows **multiple NULLs** — `NULL` never equals `NULL` in SQL, so a UNIQUE index will not stop repeated NULL rows. | | **KEY** (plain) | Speeds lookups, enforces nothing. | **Composite indexes follow the leftmost-prefix rule.** MariaDB can use the *leftmost part(s)* of a multi-column index, not an arbitrary middle slice. Given `KEY event_type_created (event_type, created_at)`: ```sql WHERE event_type = 'signup' -- ✅ uses the index WHERE event_type = 'signup' AND created_at > ? -- ✅ uses both columns WHERE created_at > ? -- ❌ cannot use it — created_at is not leftmost ``` Order composite columns by **equality filters first, ranges last**. **When an index will NOT be used:** - `LIKE '%term%'` — a **leading** wildcard defeats a BTREE index entirely (full scan). Leading-anchored `LIKE 'term%'` is fine. - A function or type-cast wrapped around the column (`WHERE DATE(created_at) = …`) — index the value you actually filter on, or store a pre-computed column. - Very small tables, where a scan is genuinely cheaper. - Low selectivity — an index on a two-value status column rarely earns its keep. **Limits (InnoDB, official):** 1,017 columns per table · **64 secondary indexes** · **32 columns per composite index** · row-size limit 65,535 bytes (BLOB/TEXT count only 9–12 bytes; their content lives outside the row). **Every index is a write tax.** Each `INSERT`/`UPDATE`/`DELETE` maintains every index on the table. Index what you filter, join and sort on — not every column. --- ## 5. Diagnosing a slow query — `EXPLAIN` and `ANALYZE` ```sql EXPLAIN SELECT * FROM wp_myplugin_events WHERE event_type = 'signup' ORDER BY created_at DESC; ``` Read these four columns first: | Column | What it tells you | |---|---| | **`type`** | How rows are reached. Best→worst: `const`, `eq_ref`, `ref`, `range`, `index`, **`ALL`**. | | **`key`** | Which index was actually chosen. **`NULL` = none used.** | | **`rows`** | Estimated rows examined per lookup. Compare against the rows you expect back. | | **`Extra`** | The warnings live here. | 🚩 **Red flags:** - **`type: ALL`** — full table scan, "bad if the table is large." - **`Using filesort`** — an extra sort pass; the `ORDER BY` isn't served by an index. - **`Using temporary`** — a temp table was built, typical of `GROUP BY` / `DISTINCT` / `ORDER BY`. - **`key: NULL`** on a large table — add the index, or fix the query so an existing one applies. **`ANALYZE`** (not `EXPLAIN`) *actually runs* the query and reports **`r_rows`** (rows really examined) and **`r_filtered`** beside the estimates. When the estimate and reality diverge wildly, the optimizer is working from stale statistics — that mismatch is the finding. `EXPLAIN FORMAT=JSON` gives the full plan for programmatic use. > **In WordPress, reach for Query Monitor first** (see `debugging.md` §2) — it shows every query with > its calling component and flags slow/duplicate ones, so you know *which* query to `EXPLAIN` before > you open a SQL client. Enable `SAVEQUERIES` to capture them. --- ## 6. MariaDB ≠ MySQL — what breaks portable SQL Your plugin will run on both. These are the officially-documented divergences that matter: - **🔴 JSON is the big one.** MariaDB does **not** support MySQL's packed/native binary JSON type — it stores JSON as ordinary **TEXT/LONGTEXT** and *compares JSON as strings, not by JSON value*. MySQL compares by value. Consequences: - The `->` and `->>` shorthand operators are MySQL-native and arrived in MariaDB only much later. **Don't use them in distributed code** — prefer `JSON_EXTRACT()` / `JSON_UNQUOTE()`, present in both, and even then test on both servers. - **Never make JSON equality or sorting the server's job.** Two documents that MySQL calls equal may differ as strings in MariaDB. - ✅ **The portable WordPress answer:** store JSON (or a serialized array) in `longtext`, filter on real indexed columns beside it, and decode in PHP. If you need to query *inside* a document, you need a proper column — that is the schema telling you something. - **`UNIX_TIMESTAMP()`** returns 6 decimal places on MariaDB and none on MySQL — a difference that silently breaks partitioning expressions and any code comparing the raw return value. Cast or round explicitly. - **`EXTRACT(HOUR FROM …)`** follows the SQL standard on MariaDB (0–23); MySQL may return larger values. - **Temporal storage formats** for `TIME` / `DATETIME` / `TIMESTAMP` differ at the byte level between MySQL 5.6 and MariaDB 10.0 — relevant to raw dumps and replication, not to normal SQL. - **Not in MariaDB:** `CREATE TABLESPACE` for InnoDB, the X Protocol, packed JSON. Encryption and GTID implementations differ and are **not** cross-compatible. > ✅ **Rule for distributed plugins: write plain, boring, standard SQL.** Stick to what both engines > have had for years. Every clever engine-specific feature is a support ticket from the half of your > users on the other one. --- ## 7. `sql_mode` — the setting that differs per server MariaDB's default since 10.2.4: `STRICT_TRANS_TABLES, ERROR_FOR_DIVISION_BY_ZERO, NO_AUTO_CREATE_USER, NO_ENGINE_SUBSTITUTION`. - **`STRICT_TRANS_TABLES`** — invalid or missing data **aborts and rolls back** the statement rather than silently coercing it. An `INSERT` omitting a `NOT NULL` column with no default *fails* here and *succeeds* on a lenient server. Always give columns sane `DEFAULT`s (as in §3). - **`ONLY_FULL_GROUP_BY`** — forbids selecting columns that are neither grouped nor aggregated. **MySQL enables it by default; MariaDB does not.** A `GROUP BY` query that works perfectly on your MariaDB dev box can error on a user's MySQL host. Select only grouped or aggregated columns. - **`NO_ZERO_DATE`** — rejects `'0000-00-00'`. Legacy WordPress data contains these; never write one. **The real lesson:** a permissive dev server hides bugs that a strict production server throws. Test against strict mode, and never rely on silent coercion. --- ## 8. Full-text search — and why usually not Full-text indexes work on **MyISAM, Aria, InnoDB and Mroonga**, on `CHAR` / `VARCHAR` / `TEXT` columns only, and **not on partitioned tables**. ```sql SELECT * FROM wp_myplugin_docs WHERE MATCH (title, body) AGAINST ('wordpress elementor' IN BOOLEAN MODE); ``` - **Natural language mode** (default) ranks by relevance; **boolean mode** supports `+` required, `-` excluded, `*` wildcard, `"…"` phrase — but does not rank by relevance. - ⚠️ **Short words are silently ignored:** under 3 characters on InnoDB, under 4 on MyISAM (`innodb_ft_min_token_size` / `ft_min_word_length`). Users searching a 2-letter term get nothing back, with no error — a classic "search is broken" report. - **Stopwords** (common words) are filtered out except in boolean mode. > **For plugins, prefer `WP_Query`'s `s` parameter** — it respects post types, statuses and > capabilities, and stays compatible with search plugins the user already runs. Reach for a > `MATCH … AGAINST` index only on your **own** custom table, when `LIKE '%term%'` (which cannot use > an index — §4) has become the measured bottleneck. --- ## 9. Querying from PHP — the short version Full security rules live in **`php-standards.md`**; the schema-side essentials: ```php global $wpdb; // ✅ Values are BOUND. Identifiers (table/column names) are never bindable — // build them from $wpdb->prefix and your own whitelist, never from user input. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, event_type, created_at FROM {$wpdb->prefix}myplugin_events WHERE user_id = %d AND created_at > %s ORDER BY created_at DESC LIMIT %d", $user_id, $since, $limit ) ); ``` - **`prepare()` needs at least one placeholder** — `prepare( $sql )` with none is the "missing argument 2" anti-pattern. An always-present `LIMIT %d` guarantees one. - **`%s` / `%d` / `%f` are not quoted by you** — `prepare()` adds the quotes. `'%s'` is a bug. - **`%i`** (WP 6.2+) safely interpolates an **identifier**; still validate it against a whitelist. - **Never `SELECT *`** on a hot path — name the columns so a **covering index** can serve the query without touching the row. - **Cache reads** (`wp_cache_get` / transients — see `php-standards.md`), and remember that under a persistent object cache transients are **not** in `wp_options`. - Use `$wpdb->prefix` for per-site tables and **`$wpdb->base_prefix`** for a network-wide table on multisite. --- > **Sources:** mariadb.com/kb — Unicode · InnoDB Limitations · Getting Started with Indexes · > EXPLAIN/ANALYZE · SQL Mode · Full-Text Index Overview · MariaDB vs MySQL Compatibility · > developer.wordpress.org/plugins/creating-tables-with-plugins/ · wordpress.org/about/requirements/ -
offcanvas-ui.md 8.8 KB
# Off-Canvas Filter UI — Full Pattern > **Native Off-Canvas widget vs this pattern — pick the right tool:** > Elementor ships a free native **Off-Canvas** widget (since ~3.23). Prefer it when the panel is > **content designed visually in the editor** (menus, a cart drawer, promo content) and a > trigger/overlay/close out of the box is enough — no code required. > Use **this custom-code pattern** when you need: a panel rendered at `wp_body_open` (outside any > transformed Elementor ancestor — see the transform trap below), a **filter UI** wired to a Loop > Grid / custom query, an `inert` + focus-trap implementation you fully control, or behaviour the > native widget doesn't expose. The two are not mutually exclusive — you can style the native > widget and reserve this pattern for bespoke cases. > Native widget docs: elementor.com/help/off-canvas-widget/ ```php // Placement: Elementor Custom Code → wp_body_open // CSS must hide the panel by default via class, NOT aria-hidden on the element itself. // aria-hidden="true" on a role="dialog" with aria-modal conflicts with ARIA spec // and causes inconsistent screen reader behaviour. Use CSS visibility instead. ?> <div id="myplugin-offcanvas" class="myplugin-offcanvas myplugin-offcanvas--hidden" role="dialog" aria-modal="true" aria-labelledby="myplugin-offcanvas-title" inert> <div class="myplugin-offcanvas__header"> <h2 id="myplugin-offcanvas-title" class="myplugin-offcanvas__title"> <?php esc_html_e( 'Filter Results', 'myplugin' ); ?> </h2> <button class="myplugin-offcanvas__close" aria-label="<?php esc_attr_e( 'Close filters', 'myplugin' ); ?>"> ✕ </button> </div> <div class="myplugin-offcanvas__body"> <!-- filter controls here --> </div> </div> <button class="myplugin-offcanvas__trigger" aria-controls="myplugin-offcanvas" aria-expanded="false"> <?php esc_html_e( 'Open Filters', 'myplugin' ); ?> </button> <!-- ✅ Backdrop overlay — click to close the panel. Placed AFTER the panel (not inside it) so it sits behind the panel in stacking context. Controlled by the same JS open/close functions via the 'myplugin-offcanvas--hidden' class. --> <!-- ✅ Backdrop starts with --hidden class in HTML — matches the panel's initial hidden state. Without this, the dark overlay is VISIBLE before JS loads and adds the class at init time. JS removes the class on open() and re-adds it on close(). Same pattern as the inert attribute on the panel: accessibility/visibility state must be set in HTML, not only in JS. --> <div class="myplugin-offcanvas__backdrop myplugin-offcanvas__backdrop--hidden" aria-hidden="true"></div> ``` ```css /* ⚠️ visibility:hidden alone does NOT block keyboard Tab into focusable children. The `inert` attribute (toggled by JS below) handles that — baseline supported in all major browsers since April 2023 (95%+ coverage). */ /* ✅ transition MUST be on the BASE element — not only on the --hidden modifier. If transition is placed only on --hidden, the open animation (class removal) runs without a transition because the rule disappears with the class. */ .myplugin-offcanvas { position: fixed; /* viewport-relative overlay — removed from document flow */ top: 0; left: 0; width: 320px; /* ✅ Use 100dvh (dynamic viewport height) NOT 100vh. On iOS Safari, 100vh is the layout viewport height which does NOT account for the browser chrome. 100dvh reflects the actual visible viewport height. Supported: Chrome 108+, Safari 15.4+, Firefox 116+. */ height: 100vh; /* fallback for browsers without dvh support */ height: 100dvh; /* ✅ correct — accounts for mobile browser chrome */ z-index: 9999; overflow-y: auto; /* ⚠️ ELEMENTOR TRANSFORM TRAP: position:fixed is broken when a parent has a non-none CSS transform (Elementor applies transforms to sections/columns). The wp_body_open placement ensures this element is a direct child of <body>, which has no transform. Never move this markup inside an Elementor widget. */ visibility: visible; pointer-events: auto; transform: translateX(0); transition: transform 0.3s ease, visibility 0.3s ease; /* ← must be here, not on --hidden */ } .myplugin-offcanvas--hidden { visibility: hidden; pointer-events: none; transform: translateX(-100%); /* Do NOT repeat transition here — the base class handles both directions */ } /* ── Backdrop overlay ───────────────────────────────────── */ .myplugin-offcanvas__backdrop { position: fixed; inset: 0; /* covers full viewport */ background: rgba( 0, 0, 0, 0.5 ); z-index: 9998; /* one below the panel (9999) */ opacity: 1; transition: opacity 0.3s ease, visibility 0.3s ease; visibility: visible; } /* Hidden state — toggled by JS alongside the panel */ .myplugin-offcanvas__backdrop.myplugin-offcanvas__backdrop--hidden { opacity: 0; visibility: hidden; pointer-events: none; } ``` ```js // Off-canvas JS — CSS class toggle + inert + ARIA expanded + focus trap + scroll lock ( () => { 'use strict'; const panel = document.getElementById( 'myplugin-offcanvas' ); const trigger = document.querySelector( '.myplugin-offcanvas__trigger' ); const close = panel?.querySelector( '.myplugin-offcanvas__close' ); if ( ! panel || ! trigger ) return; const HIDDEN_CLASS = 'myplugin-offcanvas--hidden'; // ✅ a[href] not [href] — only <a> elements with an href are natively keyboard focusable const focusable = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; const backdrop = document.querySelector( '.myplugin-offcanvas__backdrop' ); const open = () => { panel.classList.remove( HIDDEN_CLASS ); panel.inert = false; // ✅ re-enable keyboard + AT access trigger.setAttribute( 'aria-expanded', 'true' ); backdrop?.classList.remove( 'myplugin-offcanvas__backdrop--hidden' ); // ✅ iOS Safari scroll lock — overflow:hidden on body does NOT prevent background // scroll on iOS Safari. Store scroll position, fix body in place, restore on close. const scrollY = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${ scrollY }px`; document.body.style.width = '100%'; document.body.dataset.scrollY = scrollY; // ✅ Respect prefers-reduced-motion — when animations are disabled, don't wait 300ms const delay = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 0 : 300; setTimeout( () => panel.querySelector( focusable )?.focus(), delay ); }; const closePanel = () => { panel.classList.add( HIDDEN_CLASS ); panel.inert = true; // ✅ block Tab focus into hidden panel trigger.setAttribute( 'aria-expanded', 'false' ); const scrollY = parseInt( document.body.dataset.scrollY || '0', 10 ); document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; // ✅ Restore scroll BEFORE deleting dataset.scrollY — prevents data loss on rapid // open/close cycles where a second panel.open() could read a deleted value. window.scrollTo( 0, scrollY ); delete document.body.dataset.scrollY; trigger.focus(); backdrop?.classList.add( 'myplugin-offcanvas__backdrop--hidden' ); }; // Initialize panel and backdrop as hidden on load. // panel already has inert + --hidden class set in HTML markup — this is a JS-side // safety net in case the widget is dynamically injected after page load (e.g. via AJAX). panel.inert = true; backdrop?.classList.add( 'myplugin-offcanvas__backdrop--hidden' ); // idempotent — class already in HTML // Focus trap — Tab cycles within open panel // ✅ querySelectorAll runs fresh on each keydown so dynamically injected controls // (AJAX-loaded filter options) are always included in the trap panel.addEventListener( 'keydown', ( e ) => { if ( e.key !== 'Tab' ) return; const els = [ ...panel.querySelectorAll( focusable ) ]; if ( els.length === 0 ) return; const first = els[0]; const last = els[ els.length - 1 ]; if ( e.shiftKey && document.activeElement === first ) { e.preventDefault(); last.focus(); } else if ( ! e.shiftKey && document.activeElement === last ) { e.preventDefault(); first.focus(); } } ); panel.addEventListener( 'keydown', ( e ) => { if ( e.key === 'Escape' ) closePanel(); } ); trigger.addEventListener( 'click', open ); close?.addEventListener( 'click', closePanel ); // ✅ Backdrop click closes the panel — matches native dialog behaviour backdrop?.addEventListener( 'click', closePanel ); } )(); ``` -
performance.md 6.9 KB
# Performance & Accessibility Checklists ## Frontend Performance - [ ] Scripts use `strategy: defer` via WP 6.3+ API — never block parsing. **Exception: Elementor widget scripts must NOT use `defer`** — use `[ 'in_footer' => true ]` with `elementor-frontend` declared as a dependency instead (see elementor-patterns.md Step 2) - [ ] Critical above-fold CSS inlined; rest loaded asynchronously - [ ] Version strings on all enqueued assets (`MYPLUGIN_VERSION`) for cache busting - [ ] LCP image has `fetchpriority="high"` and is NOT lazy-loaded - [ ] All below-fold images have `loading="lazy"` + explicit `width`/`height` (prevents CLS). **WP 6.7+:** `auto-sizes` is prepended to the `sizes` attribute of lazy-loaded images by default — the browser uses the rendered layout width to pick the correct srcset source. Can be disabled via the `wp_img_tag_add_auto_sizes` filter (added in 6.7.1). - [ ] WebP format served; AVIF where browser support allows - [ ] `wp_add_inline_script()` used for PHP→JS data passing (not `wp_localize_script()`); `wp_json_encode()` return value checked before passing — a `false` return outputs broken JS - [ ] **WP 6.9+ IE conditional comments removed** — `wp_script_add_data( 'handle', 'conditional', 'IE' )` and `wp_style_add_data( 'handle', 'conditional', 'IE' )` now trigger a deprecation notice if `WP_DEBUG` is true, and **the asset is entirely ignored** — it will not appear on the page at all. Remove any IE-conditional asset registration from plugins and child themes. Note: bundled files that were only ever loaded conditionally for IE remain in core as **empty stub files** — do not depend on them for any actual styles or scripts. Source: make.wordpress.org/core/2025/11/19/legacy-internet-explorer-code-removed/ - [ ] **WP 6.8+ Speculative Loading** uses `prefetch` with `conservative` eagerness by default (Speculation Rules API) — only for logged-out users and only when pretty permalinks are enabled. The `wp_speculation_rules_configuration` filter receives `array( 'mode' => 'auto', 'eagerness' => 'auto' )` by default (WordPress Core resolves `'auto'` to `prefetch` + `conservative`; do not test for `'conservative'` directly — check for `'auto'` or the resolved output instead). If your plugin uses **one-time nonces**, **session state writes**, or **cart/checkout side-effects** that trigger on page load, exclude those URLs via the `wp_speculation_rules_href_exclude_paths` filter. Add the `no-prefetch` CSS class to a link *or its container element* to exclude that link (and all descendant links) from Core's prefetch speculation rule — WP Core's selector is `.no-prefetch, .no-prefetch a`; use `no-prerender` for the prerender rule (selector: `.no-prerender, .no-prerender a`). Test coverage with DevTools → Application → Speculation Rules. --- ## Backend / Database Performance - [ ] `no_found_rows => true` on WP_Query when pagination is absent - [ ] `update_post_meta_cache => false`, `update_term_meta_cache => false` when metadata not needed - [ ] Expensive queries or remote calls wrapped in `get_transient` / `set_transient` - [ ] Transient keys are ≤ 172 characters — dynamic keys use `md5()` to prevent silent cache misses - [ ] No `SELECT *` — use `$wpdb->get_results` with explicit column names - [ ] No queries inside loops — pre-fetch with a single WP_Query, then loop results - [ ] **Slow query → run `EXPLAIN` before guessing.** `type: ALL` or `key: NULL` on a large table means no index is being used; `Using filesort` / `Using temporary` mean the sort or grouping isn't served by one. Composite indexes only apply **leftmost-first**, and `LIKE '%term%'` can never use a BTREE index. Full column/index reference: **`mariadb.md`** §4–§5. - [ ] Custom tables: index what you **filter, join and sort** on — and no more; every index is a write tax on `INSERT`/`UPDATE`/`DELETE` (`mariadb.md` §4) - [ ] **WP 7.0 Real-Time Collaboration (RTC) — scope your queries:** WP 7.0 (released May 20, 2026) stores RTC collaboration data in a **dedicated core database table**, not in `wp_posts`/`wp_post_meta`. (An earlier `wp_sync_storage` post-meta design was rejected; building the table is what delayed 7.0 from April to May.) Because the data lives in its own table, general post queries are not polluted by it — but the durable best practice stands: give every `WP_Query`/`get_posts()` an explicit `'post_type' => 'your_cpt'` rather than relying on defaults, so no future internal post type can ever leak into your results. Do not hardcode exclusions against internal core type slugs. Source: make.wordpress.org/core/2026/04/22/wordpress-7-0-release-party-updated-schedule/ - [ ] **WP 6.9+ WP_Query cache key change** — WordPress 6.9 changed how cache keys are generated for queries performed through `WP_Query` (and other query classes: `WP_Term_Query`, `WP_Comment_Query`, `WP_User_Query`, etc.). Cache keys now store alongside the last-changed timestamp and validate it on retrieval, instead of embedding it in the key. This is compatible with all existing persistent object cache drop-ins. However: if your plugin directly reads or writes to cache groups such as `post-queries`, `term-queries`, `comment-queries`, `user-queries`, or checks specific keys, review against the four new functions introduced in WP 6.9: `wp_cache_get_salted()`, `wp_cache_set_salted()`, `wp_cache_get_multiple_salted()`, `wp_cache_set_multiple_salted()`. **The order of items in the array of salts must be consistent** or cache hits will be missed. Source: make.wordpress.org/core/2025/11/17/consistent-cache-keys-for-query-groups-in-wordpress-6-9/ --- ## Accessibility Checklist (WCAG 2.2 AA) - [ ] Interactive elements use `<button>` or `<a>` — never `<div onClick>` or `<span onClick>` - [ ] All `<img>` have descriptive `alt`; decorative images use `alt=""` - [ ] Color contrast ≥ 4.5:1 for body text; ≥ 3:1 for large text and UI components - [ ] `:focus-visible` styles present and visible on every interactive element - [ ] Off-canvas / modal panels use `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, Escape key close, focus trap, **CSS class toggle for visibility**, and **`inert` attribute toggled by JS** to block keyboard Tab into hidden panels — never `aria-hidden` on a `role="dialog"` element (ARIA spec conflict); `visibility:hidden` alone does not block keyboard focus (see offcanvas-ui.md for the complete pattern) - [ ] Dynamic content changes announced via `aria-live="polite"` (non-critical) or `"assertive"` (errors) - [ ] Form labels explicitly associated via `for`/`id` pairing or `aria-labelledby` - [ ] Full keyboard navigation without mouse — Tab, Shift+Tab, Enter, Escape all functional -
php-standards.md 9.8 KB
# PHP Standards ## Sanitization, Escaping, and Nonces ```php // INPUT — always sanitize immediately on receipt // ✅ wp_unslash() BEFORE sanitize_text_field / sanitize_email / wp_kses_post / sanitize_url. // WordPress magic-quotes all $_POST/$_GET superglobals. Without wp_unslash() first, // a value like "O'Reilly" is sanitized as "O\'Reilly" and stored with corrupt backslashes. // Exception: absint() — integers cannot contain slashes, so wp_unslash() is not needed. // Exception: sanitize_key() — strips slashes implicitly (see WPCS wiki). $name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) ); $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); $id = absint( $_GET['post_id'] ?? 0 ); $content = wp_kses_post( wp_unslash( $_POST['content'] ?? '' ) ); // ✅ sanitize_url() is the canonical function for URL input sanitization. // History: existed since WP 2.3.1, deprecated in WP 2.8.0 in favour of esc_url_raw(), // then UN-DEPRECATED and restored as canonical in WP 5.9.0 to honour the naming convention // "use sanitize_ to sanitize, esc_ to escape". esc_url_raw() is now an alias for sanitize_url(). // Use sanitize_url() — it is correct, un-deprecated, and WPCS-clean on WP 5.9+. // Source: make.wordpress.org/plugins/2022/05/25/rejoice-to-sanitize_url/ // developer.wordpress.org/reference/functions/sanitize_url/ (restored since 5.9) // // ⚠️ WP 6.9 BEHAVIOUR CHANGE — esc_url(), esc_url_raw(), and sanitize_url(): // In WP 6.8 and earlier, a protocol-less URL (e.g. "example.com/path") was prepended // with http:// before processing. Starting in WP 6.9, if 'https' is the first item in // the $protocols argument (which it IS by default), the functions prepend https:// instead. // Test any code that passes bare URLs (no scheme) through these functions after upgrading. // Source: make.wordpress.org/core/2025/11/19/url-escaping-functions-can-support-https-as-the-default-protocol-in-wordpress-6-9/ $url = sanitize_url( wp_unslash( $_POST['redirect'] ?? '' ) ); // OUTPUT — always escape at the point of output echo esc_html( $name ); echo esc_attr( $name ); // inside HTML attributes echo esc_url( $url ); // href, src echo wp_kses_post( $content ); // rich HTML content // NONCES — on every form and AJAX action // Render: wp_nonce_field( 'myplugin_save_action', 'myplugin_nonce' ); // Verify: // ✅ sanitize_key() is the WPCS-canonical sanitizer for nonces — it strips slashes // automatically so wp_unslash() is not required (unlike sanitize_text_field). if ( ! isset( $_POST['myplugin_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['myplugin_nonce'] ), 'myplugin_save_action' ) ) { wp_die( esc_html__( 'Security check failed.', 'myplugin' ) ); } ``` --- ## WP_Error and External API Calls ```php // Always use WP_Error for communicable failures function myplugin_fetch_data( int $post_id ): array|WP_Error { if ( $post_id <= 0 ) { return new WP_Error( 'invalid_id', __( 'Invalid post ID.', 'myplugin' ) ); } $response = wp_remote_get( 'https://api.example.com/data/' . $post_id, [ 'timeout' => 10, 'sslverify' => true ] ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return new WP_Error( 'api_error', sprintf( /* translators: %d: HTTP response code */ __( 'API returned status %d.', 'myplugin' ), $code ), [ 'status' => $code ] ); } $body = wp_remote_retrieve_body( $response ); $decoded = json_decode( $body, true ); return is_array( $decoded ) ? $decoded : []; } // Caller always checks for errors $data = myplugin_fetch_data( $post_id ); if ( is_wp_error( $data ) ) { // ✅ Guard error_log() with WP_DEBUG — raw error messages can leak sensitive // data (API keys, user data) into server logs on hosts where WP_DEBUG_LOG is enabled. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'myplugin: ' . $data->get_error_message() ); } } ``` --- ## Transient Caching ```php // ⚠️ TRANSIENT KEY LIMIT: 172 characters maximum (WordPress enforces this silently — // keys over the limit appear to save but are never retrieved). // For dynamic keys built from multiple segments, use md5(): // ✅ Simple key — safe $cache_key = 'myplugin_data_' . $post_id; // ✅ Dynamic key — use md5() to guarantee safe length $cache_key = 'myplugin_' . md5( $post_id . '_' . $user_id . '_' . $locale ); function myplugin_get_cached_data( int $post_id ): array { $cache_key = 'myplugin_data_' . $post_id; $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $data = myplugin_fetch_data( $post_id ); if ( ! is_wp_error( $data ) ) { set_transient( $cache_key, $data, HOUR_IN_SECONDS ); } return is_wp_error( $data ) ? [] : $data; } ``` > ⚠️ **Under a persistent object cache (Redis/Memcached), transients are NOT in `wp_options`.** > A direct `DELETE … LIKE '_transient_%'` on the options table silently no-ops — "revoke all" / > "clear all" *appears* to work but nothing is invalidated (a real production bug). Never > bulk-delete transients with SQL. For group invalidation, keep an options-backed **generation > counter** (the counter is part of every cache key; bump it to invalidate everything at once) or > a registry of known keys removed via `delete_transient()`. --- ## General PHP Rules ```php // ✅ Bail early — avoid deeply nested conditionals function myplugin_process(): void { if ( ! is_user_logged_in() ) return; if ( ! current_user_can( 'edit_posts' ) ) return; // ... proceed } // ✅ Prefix ALL global symbols function myplugin_my_function(): void {} class MyPlugin_Utility {} define( 'MYPLUGIN_DEBUG', false ); do_action( 'myplugin/after_save', $post_id ); // ✅ OOP for anything beyond ~20 lines // ✅ Use WP_Query / get_posts() — avoid raw $wpdb unless unavoidable // ✅ Never suppress errors with @ — fix the root cause // ✅ PHP 8.2+ readonly classes — ideal for immutable plugin config/value objects readonly class MyPlugin_Config { public function __construct( public string $api_key, public int $cache_ttl, public string $endpoint, ) {} } // ✅ PHP 8.3+ typed class constants — prefer over untyped define() in OOP contexts class MyPlugin_Constants { const string VERSION = '1.0.0'; const int CACHE_TTL = 3600; } ``` > ⚠️ **`declare(strict_types=1)` placement rule:** Must be the **very first statement** in > its file, immediately after `<?php` — before any output, namespace, use, require, or other > statements. Every PHP file that uses it must open like this: > ```php > <?php > declare( strict_types=1 ); > // ... rest of file > ``` --- ## WP 6.8+ Password Hashing (bcrypt / BLAKE2b) > ⚠️ **Breaking change in WP 6.8 (April 2025):** WordPress replaced the old phpass > MD5-based scheme with **bcrypt** for user passwords and **BLAKE2b** for application > passwords and security keys. > > **Hash prefix reference (WP 6.8+):** > - `$wp$2y$` — bcrypt (user passwords via `wp_hash_password()`). The `$wp` prefix > distinguishes WordPress's pre-hashed bcrypt from vanilla bcrypt used by third-party plugins. > - `$generic$` — BLAKE2b via Sodium (application passwords, password reset keys, personal > data request keys, recovery mode key), used via `wp_fast_hash()`. > - `$P$` — Legacy phpass hash (still valid; rehashed opportunistically on next login). > - **Post passwords** still use phpass in WP 6.8. > > Source: make.wordpress.org/core/2025/02/17/wordpress-6-8-will-use-bcrypt-for-password-hashing/ ```php // ✅ ALWAYS use wp_check_password() — never raw string comparison against user_pass. if ( wp_check_password( $plain_password, $stored_hash, $user->ID ) ) { // ✅ WP 6.8+: check whether the stored hash needs upgrading to bcrypt. if ( wp_password_needs_rehash( $stored_hash ) ) { // ✅ wp_set_password() takes the PLAINTEXT password and hashes internally. // Do NOT pre-hash with wp_hash_password() — double-hashing permanently locks users out. // // ✅ wp_set_password() does NOT send any password-change email and does NOT fire // 'after_password_reset'. It only fires the 'wp_set_password' action (WP 6.2+). // 'after_password_reset' is fired by WordPress's higher-level reset_password() // function, which calls wp_set_password() internally. Calling wp_set_password() // directly (as in this rehash flow) does not trigger that hook at all. // Source: developer.wordpress.org/reference/functions/wp_set_password/ // developer.wordpress.org/reference/functions/reset_password/ // // ⚠️ SESSION INVALIDATION: wp_set_password() invalidates ALL existing auth cookies // for this user immediately. Call wp_set_auth_cookie() afterward to re-issue a valid // cookie for the current session if you want the user to stay logged in. wp_set_password( $plain_password, $user->ID ); } } // ✅ To hash a password: always wp_hash_password() — never password_hash() directly. $hash = wp_hash_password( $plain_password ); // ❌ NEVER inspect hash prefixes directly — they changed in WP 6.8 and may change again. // ✅ wp_fast_hash() / wp_verify_fast_hash() — BLAKE2b via Sodium. // ⚠️ IMPORTANT: HIGH-ENTROPY RANDOM INPUT ONLY (>128 bits). // MUST NOT be used for user passwords (use wp_hash_password()). // MUST NOT be used for low-entropy input such as user IDs, emails, short tokens // (use wp_hash() for those). Intended only for randomly-generated high-entropy secrets // such as application passwords and security keys. $hash = wp_fast_hash( $random_secret ); $valid = wp_verify_fast_hash( $input, $hash ); // returns bool ``` -
rest-api.md 5.8 KB
# REST API Endpoints ```php // Always in a plugin — never in functions.php add_action( 'rest_api_init', function() { register_rest_route( 'myplugin/v1', '/items/(?P<id>\d+)', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => 'myplugin_rest_get_item', 'permission_callback' => 'myplugin_rest_permissions', 'args' => [ 'id' => [ // ✅ Use WP's built-in schema validation — NOT a custom is_numeric() closure. // is_numeric() accepts floats ('1.5'), negatives ('-1'), and scientific notation // ('1e5'), none of which are valid post IDs. The 'type' + 'minimum' + // 'rest_validate_request_arg' pattern is the canonical WP REST API approach. 'type' => 'integer', 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'required' => true, 'description' => 'The ID of the item to retrieve.', ], ], // ✅ Always include a schema callback — required for REST API schema discovery, // client code generation, and WP-CLI route inspection. 'schema' => 'myplugin_get_item_schema', ] ); } ); /** * JSON Schema for the /items/{id} endpoint. */ function myplugin_get_item_schema(): array { return [ // ✅ WordPress REST API uses JSON Schema draft-04 — NOT draft-07. // WP core validates against draft-04 via its own schema validator. Using // draft-07 keywords (e.g. boolean 'exclusiveMinimum', 'if/then/else', '$ref' resolution) // will be silently ignored or cause validation errors on WP 6.x+. // Source: developer.wordpress.org/rest-api/extending-the-rest-api/schema/ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'myplugin_item', 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', 'description' => 'Unique post ID.' ], 'title' => [ 'type' => 'string', 'description' => 'Post title.' ], 'content' => [ 'type' => 'string', 'description' => 'Post content (filtered HTML).' ], ], ]; } function myplugin_rest_permissions( WP_REST_Request $request ): bool|WP_Error { // Return WP_Error (not false) on denial — gives client a proper JSON error body. // Returning false sends a generic 403 with no context. if ( ! current_user_can( 'read' ) ) { return new WP_Error( 'rest_forbidden', __( 'You do not have permission to access this resource.', 'myplugin' ), [ 'status' => 403 ] ); } return true; } // ─── REST API Nonce — Authenticating JS Fetch Calls ───────────────────────── // // WordPress REST API uses a SEPARATE nonce from AJAX (wp_ajax_*). // The REST nonce is generated with: wp_create_nonce( 'wp_rest' ) // It must be sent as the X-WP-Nonce request header (not a POST body param). // // ✅ CORRECT — pass nonce to JS via wp_add_inline_script(): // $data = wp_json_encode( [ 'nonce' => wp_create_nonce( 'wp_rest' ), 'restUrl' => rest_url() ] ); // wp_add_inline_script( 'myplugin-js', 'const mypluginRest = ' . $data . ';', 'before' ); // // ✅ CORRECT — JS fetch with nonce header: // const res = await fetch( mypluginRest.restUrl + 'myplugin/v1/items/1', { // headers: { 'X-WP-Nonce': mypluginRest.nonce, 'Content-Type': 'application/json' }, // } ); // // ⚠️ REST nonces expire after 24 hours. If your SPA/page stays open overnight, handle // 403 (rest_cookie_invalid_nonce) responses by fetching a fresh nonce and retrying: // // ✅ CORRECT nonce refresh — WordPress core registers wp_ajax_rest_nonce() on the // 'wp_ajax_rest_nonce' action (note: underscore, not hyphen). Calling this endpoint // with logged-in credentials returns a fresh wp_rest nonce as plain text: // Source: developer.wordpress.org/reference/functions/wp_ajax_rest_nonce/ // // const refreshed = await fetch( ajaxUrl + '?action=rest_nonce', { credentials: 'include' } ); // mypluginRest.nonce = ( await refreshed.text() ).trim(); // // then retry the original request with the new nonce header // // ⚠️ The action is 'rest_nonce' (underscore) not 'rest-nonce' (hyphen). // ⚠️ User must be logged in — this endpoint returns a nonce for cookie-based auth only. // // ⚠️ Public endpoints (no authentication required): use '__return_true' as permission_callback. // Never use '__return_false' — that permanently blocks all access including admins. // register_rest_route( 'myplugin/v1', '/public-data', [ // 'methods' => WP_REST_Server::READABLE, // 'callback' => 'myplugin_public_callback', // 'permission_callback' => '__return_true', // ] ); function myplugin_rest_get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = $request->get_param( 'id' ); $post = get_post( $id ); if ( ! $post || 'publish' !== $post->post_status ) { return new WP_Error( 'not_found', __( 'Item not found.', 'myplugin' ), [ 'status' => 404 ] ); } // ✅ Set up global post context before apply_filters('the_content'). // Without this, shortcodes, Gutenberg blocks, embeds, and any plugin that // reads the global $post will fail silently or produce wrong output. // wp_reset_postdata() restores the previous global $post state after rendering. $GLOBALS['post'] = $post; setup_postdata( $post ); $response = rest_ensure_response( [ 'id' => $post->ID, 'title' => get_the_title( $post ), 'content' => apply_filters( 'the_content', $post->post_content ), ] ); wp_reset_postdata(); return $response; } ``` -
scaffolding.md 11.3 KB
# Scaffolding — Plugin, Child Theme, CPT, AJAX ## Code Placement Rules **Always declare placement at the top of every code response.** | Destination | When to use | |---|---| | Child theme `functions.php` | Lightweight theme-scoped hooks, no reusability needed | | Elementor → Site Settings → Custom Code | JS/CSS snippets injected at `wp_head`, `wp_footer`, `wp_body_open` | | Dedicated plugin `includes/` class | Reusable logic, CPTs, REST endpoints, widget registration | | `wp-content/mu-plugins/` | Must-load logic, network-wide on multisite, security-critical code | | Elementor Widget PHP file | Custom `\Elementor\Widget_Base` extension, registered via hook | | `wp-content/themes/mytheme-child/woocommerce/` | WooCommerce template overrides — **last resort only** | --- ## Plugin Scaffolding Use when logic is reusable, complex, or must survive theme changes. ### File structure ``` /wp-content/plugins/myplugin/ ├── myplugin.php ← Main bootstrap file ├── includes/ │ ├── class-myplugin.php ← Singleton init class │ ├── class-myplugin-hooks.php ← All add_action / add_filter │ ├── class-myplugin-assets.php ← Enqueue logic │ ├── class-myplugin-rest.php ← REST API endpoints (if needed) │ └── class-myplugin-widget.php ← Elementor widget (if needed) ├── assets/ │ ├── css/myplugin.css │ └── js/myplugin.js └── README.md ``` ### Main plugin file (always use this header): ```php <?php /** * Plugin Name: My Plugin * Description: Short description. * Version: 1.0.0 * Requires at least: 6.9 * Requires PHP: 8.3 * Requires Plugins: elementor * Author: Your Name * License: GPL-2.0-or-later * Text Domain: myplugin */ // ↑ "Requires Plugins" is the WP 6.5+ native plugin dependency declaration. // WordPress.org plugin slugs only — do NOT add inline comments on that line; // get_file_data() reads everything after the colon as the value, including comments. // NOTE: WordPress 7.0 (released May 2026) raised the minimum PHP to 7.4. Set "Requires PHP" // to at least 7.4 once you target WP 7.0+ sites exclusively (8.3 remains the recommended baseline). defined( 'ABSPATH' ) || exit; define( 'MYPLUGIN_VERSION', '1.0.0' ); define( 'MYPLUGIN_PATH', plugin_dir_path( __FILE__ ) ); define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) ); // Bail early if Elementor is not active (when Elementor features are used) add_action( 'plugins_loaded', function() { if ( ! did_action( 'elementor/loaded' ) ) { add_action( 'admin_notices', function() { echo '<div class="notice notice-warning"><p>' . esc_html__( 'My Plugin requires Elementor to be active.', 'myplugin' ) . '</p></div>'; } ); return; } require MYPLUGIN_PATH . 'includes/class-myplugin.php'; MyPlugin::instance()->init(); } ); // ✅ WooCommerce HPOS compatibility declaration. // HPOS is enabled by default for new WooCommerce stores since v8.2 (October 2023). // Without this declaration, WooCommerce shows a blocking admin warning. // Remove this block if your plugin does not interact with WooCommerce at all. add_action( 'before_woocommerce_init', function() { if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', // HPOS feature identifier — do not change this string __FILE__, true // true = compatible; false = not compatible ); } } ); ``` ### Singleton init class pattern: ```php <?php // includes/class-myplugin.php defined( 'ABSPATH' ) || exit; class MyPlugin { private static ?self $instance = null; public static function instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } public function init(): void { require MYPLUGIN_PATH . 'includes/class-myplugin-hooks.php'; require MYPLUGIN_PATH . 'includes/class-myplugin-assets.php'; ( new MyPlugin_Hooks() )->register(); ( new MyPlugin_Assets() )->register(); } } ``` --- ## Child Theme Scaffolding Use when customizing an existing theme without a dedicated plugin. ### File structure ``` /wp-content/themes/mytheme-child/ ├── style.css ← Required header; do NOT import parent here ├── functions.php ← Enqueue parent + child styles; all hooks go here └── screenshot.png ← 1200×900px recommended ``` ### style.css header: ```css /* * Theme Name: My Theme Child * Template: parent-theme-folder-name * Version: 1.0.0 * Text Domain: mytheme-child */ ``` ### functions.php — correct enqueue pattern: ```php <?php defined( 'ABSPATH' ) || exit; /** * Enqueue parent and child theme stylesheets. * Never use @import in style.css — always use wp_enqueue_style(). */ add_action( 'wp_enqueue_scripts', 'mytheme_child_enqueue_styles' ); function mytheme_child_enqueue_styles(): void { $parent_style = 'parent-theme-style'; // match parent theme's registered handle wp_enqueue_style( $parent_style, get_template_directory_uri() . '/style.css', [], wp_get_theme( get_template() )->get( 'Version' ) ); wp_enqueue_style( 'mytheme-child-style', get_stylesheet_uri(), [ $parent_style ], wp_get_theme()->get( 'Version' ) ); } ``` --- ## Custom Post Type Registration ```php // Placement: dedicated plugin — never in functions.php // Always register on 'init', never on 'after_setup_theme' or earlier add_action( 'init', 'myplugin_register_post_types' ); function myplugin_register_post_types(): void { register_post_type( 'myplugin_item', [ 'labels' => [ 'name' => _x( 'Items', 'post type general name', 'myplugin' ), 'singular_name' => _x( 'Item', 'post type singular name', 'myplugin' ), 'add_new_item' => __( 'Add New Item', 'myplugin' ), 'edit_item' => __( 'Edit Item', 'myplugin' ), 'view_item' => __( 'View Item', 'myplugin' ), 'search_items' => __( 'Search Items', 'myplugin' ), 'not_found' => __( 'No items found.', 'myplugin' ), 'not_found_in_trash' => __( 'No items found in Trash.', 'myplugin' ), ], 'public' => true, 'has_archive' => true, 'show_in_rest' => true, // required for Gutenberg + Elementor Loop Grid 'supports' => [ 'title', 'editor', 'thumbnail', 'custom-fields' ], 'menu_icon' => 'dashicons-portfolio', 'rewrite' => [ 'slug' => 'items', 'with_front' => false ], ] ); // Register associated taxonomy register_taxonomy( 'myplugin_category', 'myplugin_item', [ 'labels' => [ 'name' => _x( 'Item Categories', 'taxonomy general name', 'myplugin' ), 'singular_name' => _x( 'Item Category', 'taxonomy singular name', 'myplugin' ), ], 'hierarchical' => true, 'public' => true, 'show_in_rest' => true, // required for Elementor Loop Grid filtering 'rewrite' => [ 'slug' => 'item-category' ], ] ); } // ⚠️ FLUSH REWRITE RULES: only on activation — NEVER on 'init' (too expensive). // register_activation_hook( __FILE__, function() { // myplugin_register_post_types(); // register CPTs so rules exist before flush // flush_rewrite_rules(); // } ); // Alternatively: Dashboard → Settings → Permalinks → Save Changes (manual one-time flush). ``` --- ## AJAX Handler Pattern Complete PHP + JS pattern for secure WordPress AJAX: ```php // Placement: includes/class-myplugin-hooks.php // Register handlers — add wp_ajax_nopriv_ only if action is public-facing add_action( 'wp_ajax_myplugin_action', 'myplugin_ajax_handler' ); add_action( 'wp_ajax_nopriv_myplugin_action', 'myplugin_ajax_handler' ); /** * All wp_send_json_* functions call wp_die() internally. * Use check_ajax_referer() (not wp_verify_nonce) in AJAX handlers — * it is the idiomatic WP pattern, fires the check_ajax_referer action * hook for security logging, and correctly dies on failure. */ function myplugin_ajax_handler(): void { // 1. Verify nonce — dies automatically with -1 output and 403 header on failure check_ajax_referer( 'myplugin_ajax', 'nonce' ); // 2. Check capability — nonces confirm request origin, NOT permission if ( ! current_user_can( 'read' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'myplugin' ) ], 403 ); return; } // 3. Sanitize and validate input $item_id = absint( $_POST['item_id'] ?? 0 ); if ( $item_id <= 0 ) { wp_send_json_error( [ 'message' => __( 'Invalid item ID.', 'myplugin' ) ], 400 ); return; } // 4. Process and respond — wp_send_json_success calls wp_die() internally $data = myplugin_get_cached_data( $item_id ); wp_send_json_success( $data ); } ``` ```js // JS side — pairs with the wp_add_inline_script data (see js-css-standards.md) const fetchItem = async ( itemId ) => { const body = new FormData(); body.append( 'action', 'myplugin_action' ); body.append( 'nonce', myPluginData.nonce ); body.append( 'item_id', itemId ); try { const res = await fetch( myPluginData.ajaxUrl, { method: 'POST', body } ); const json = await res.json(); if ( ! json.success ) { console.error( 'myplugin AJAX error:', json.data?.message ); return null; } return json.data; } catch ( err ) { console.error( 'myplugin fetch failed:', err ); return null; } }; ``` --- ## Elementor Widget Registration Reminder When registering an Elementor widget from a plugin (via `elementor/widgets/register`), **every widget class must implement these two methods** — they are required for V4 compatibility and output caching. Forgetting them in any widget (including those scaffolded from this file) will cause rendering issues and PHP warnings in future Elementor versions. ```php // ✅ REQUIRED on EVERY new widget — removes the redundant inner wrapper div. // Elementor 3.26+. Without this, the deprecated .elementor-widget-container wrapper // is still rendered, breaking Optimized Markup mode and V4 Atomic elements. // Return true ONLY if your render() output physically requires that wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ REQUIRED on EVERY widget — controls Elementor output caching. // Return false → Elementor may cache the rendered HTML (safe for static/non-user-specific output). // Return true → Elementor skips caching (required if output varies per user, session, or time). // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; // change to true if output is user/session/time-specific } ``` > **See `elementor-patterns.md` for the complete widget boilerplate**, and the relevant > `widget-*.md` file for control patterns matching Elementor's native widgets. -
SKILL.md 37.7 KB
--- name: wordpress-elementor-dev description: > Expert WordPress and Elementor Pro development skill. Use this whenever the user asks about WordPress theme development, Elementor Pro customization, plugin architecture, custom post types, hooks/filters, Loop Grids, Dynamic Tags, Theme Builder templates, off-canvas UIs, custom CSS/JS injection, performance optimization, or any PHP/JS/CSS code targeting a WordPress or Elementor environment. Trigger even for general questions like "how do I add a custom field in Elementor", "what hook should I use for X in WordPress", "how do I override a WooCommerce template", or "how do I register a custom widget" — do not wait for the user to explicitly say "use the skill." Also trigger for ACF integration, REST API endpoints, child theme setup, WooCommerce customization, WPML/multilingual setups, and any debugging of WordPress/Elementor issues. --- # WordPress & Elementor Pro — Skill Router A complete procedural guide for producing production-grade WordPress and Elementor Pro code. This file is the **router** — read it first, then load the relevant sub-file(s) for the task at hand. --- ## Sub-file Map — Read the Right File for Each Task | Task type | Read this file | |---|---| | Plugin scaffold, child theme, code placement, CPT, AJAX handler | **scaffolding.md** | | PHP standards, sanitization, escaping, nonces, WP_Error, transients, password hashing | **php-standards.md** | | JavaScript standards, enqueue API, defer/async, wp_add_inline_script | **js-css-standards.md** | | CSS standards, BEM, design tokens, Elementor CSS selectors | **js-css-standards.md** | | Elementor custom widget, Dynamic Tags, Loop Grid, Form actions, Theme Builder conditions | **elementor-patterns.md** | | Elementor extension points — custom **form fields**, **theme locations**, injecting controls into native widgets, **adding a TAB to the editor's Elements panel**, Finder/context-menu, hooks reference, deprecations | **elementor-extending.md** | | WooCommerce HPOS, order API, template overrides, Loop Grid for products | **woocommerce.md** | | REST API endpoints, schema, permission callbacks | **rest-api.md** | | Off-canvas UI, off-canvas accessibility, focus trap | **offcanvas-ui.md** | | Performance checklists (frontend + backend), speculative loading, IE conditional comments | **performance.md** | | Accessibility checklist, WCAG 2.2 AA, ARIA patterns | **performance.md** | | **Hard-won production gotchas** — widget lifecycle fatals, `content_template()` escaping, CSS-in-Elementor footguns, transactional email, wp.org review/packaging, embedding apps, AJAX/loop re-rendering, dynamic CSS in loops, Swiper on optimised sites, holding a login without counting it as failed | **field-notes.md** | | **wordpress.org submission** — the 18 Directory Guidelines, Plugin Check 2.1.0 categories/usage, review process, required headers/readme | **wp-org-guidelines.md** | | **Subversion (SVN)** — deploying/tagging a release on the wordpress.org plugin/theme SVN (trunk/tags/assets), the daily work cycle, branching/merging, properties, repo admin | **svn/svn.md** (self-contained sub-bundle; `svn/references/` goes deeper) | | **Debugging & static analysis** — PHPCS+WPCS, PHPStan, Plugin Check, `WP_DEBUG`/Query Monitor, Elementor Safe Mode/cache, symptom→cause table | **debugging.md** | | **Common WordPress APIs** — admin settings page (Settings + Options API), `register_meta`, roles/capabilities, WP-Cron, internationalization (i18n) | **wordpress-apis.md** | | **Database layer (MariaDB/MySQL)** — custom tables + `dbDelta()`, charsets & the `varchar(191)` index rule, indexes, `EXPLAIN`, MariaDB↔MySQL portability, `sql_mode`, full-text | **mariadb.md** | ### Widget Boilerplates — Load when building a widget of that type | Widget type | Read this file | |---|---| | Button / CTA with icon and link | **widget-button.md** | | Container / layout wrapper / section / card shell | **widget-container.md** | | Image with caption, link, lightbox | **widget-image.md** | | Heading / title / HTML tag selector | **widget-heading.md** | | Rich text / WYSIWYG body content | **widget-text-editor.md** | | Video embed (YouTube, Vimeo, self-hosted) | **widget-video.md** | | Widget that renders a saved Elementor template by ID | **widget-elementor-template.md** (SELECT2, `get_builder_content_for_display()`, Dynamic Tags context, CSS timing) | | Widget whose markup lives in a separate PHP template file | **widget-php-template.md** (Strategies A/B/C, `load_template()`, `ob_start`, path-traversal safety) | | Divider / horizontal rule with optional text or icon | **widget-divider.md** | | Spacer / vertical gap | **widget-spacer.md** | | Single standalone icon with optional link | **widget-icon.md** | | Icon + title + description box | **widget-icon-box.md** | | Image + title + description box | **widget-image-box.md** | | Image grid / gallery | **widget-image-gallery.md** | | Image slider / carousel | **widget-image-carousel.md** | | Bullet list with icons per item | **widget-icon-list.md** | | Animated number counter | **widget-counter.md** | | Percentage progress bar | **widget-progress.md** | | Customer quote / testimonial | **widget-testimonial.md** | | Tabbed content panels | **widget-tabs.md** | | Accordion — one panel open at a time | **widget-accordion.md** | | Toggle — multiple panels open simultaneously | **widget-toggle.md** | | Social media icon links row | **widget-social-icons.md** | | Colored alert / notice box | **widget-alert.md** | | Audio player (SoundCloud / self-hosted) | **widget-audio.md** | | WordPress shortcode output | **widget-shortcode.md** | | Raw custom HTML / JS / CSS embed | **widget-html.md** | | Named anchor for in-page navigation | **widget-menu-anchor.md** | | WordPress registered sidebar output | **widget-sidebar.md** | | WordPress <!--more--> read more tag | **widget-read-more.md** | | Google Maps embed | **widget-google-maps.md** | | Decorative star rating display | **widget-star-rating.md** | | Schema-ready structured rating | **widget-rating.md** | | Text following a curved / custom SVG path | **widget-text-path.md** | | Nested Tabs / Nested Accordion (each panel is a Container holding any widgets) | **widget-nested.md** | **Always read the relevant sub-file before writing code.** For tasks that span multiple areas (e.g. a WooCommerce widget with custom REST endpoint), read all relevant sub-files. For a widget task, read BOTH the widget boilerplate file AND **elementor-patterns.md**. For **any custom widget, plugin, or wp.org-bound work, also skim `field-notes.md`** — it catches the lint-passing, review-failing, site-down mistakes the topic files don't dwell on. --- ## 0. Golden Rules (Never Violate) These override everything in all sub-files: 1. **Native APIs first** — WordPress core hook before plugin; Elementor API before template override. 2. **Sanitize in, escape out** — Every input sanitized. Every output escaped. No exceptions. 3. **Prefix everything** — All functions, classes, constants, hooks, and CSS classes use a project-specific prefix. 4. **State your placement** — Every code response must declare exactly where the code lives. 5. **No over-clarifying** — Only ask a clarification question if the missing info would materially change the code output. Otherwise, state your assumption and proceed. 6. **NEVER hardcode visual settings in widgets** — Every visual property (colors, fonts, sizes, spacing, backgrounds, borders, shadows, alignment) MUST be exposed as a standard Elementor control in the editor panel. Users control appearance via the toolbar — not by editing code. See §5 "Mandatory Widget Controls" below for the required controls checklist. 7. **Name for the directory from day one** — Naming is decided first and is effectively irreversible, so get it right before writing code. The plugin **slug/name must NOT start with a trademark you don't own** — `"CRM for WooCommerce"`, never `"WooCommerce CRM"` (Directory Guideline 17). The **text domain must exactly equal the plugin slug**. The wp.org slug is **permanent**, and a widget's `get_name()` is **sticky** (stored in every page's `_elementor_data` — renaming it breaks placed widgets). Choose the public name, slug, text domain, and code prefix (Rule #3) **together, once**. This is distinct from Rule #3: that governs internal code symbols; this governs the public identity. See **wp-org-guidelines.md** (Guidelines 12/16/17) and **field-notes.md** §1 (sticky `get_name()`). --- ## 1. Default Assumptions Quickly assess — **only ask if the answer would change the code**: | Info needed | Ask only if... | |---|---| | Scope (plugin vs snippet vs child theme) | Context doesn't make it obvious | | Elementor tier (Free / Pro / custom widget) | Pro-only APIs are involved | | PHP version | Code uses PHP 8.3+ features like typed class constants, or 8.4+ features like property hooks | | WooCommerce / ACF / WPML present | Integration with those systems is required | **Default stack when not stated** (full release-by-release history lives in **CHANGELOG.md** — keep volatile version-tracking out of this router): | Component | Version | Notes | |---|---|---| | **WordPress** | **7.1** | **"Mary Lou", released Aug 19, 2026** (7.0 "Armstrong" was May 20; the 7.0 branch ended at 7.0.4, Aug 12). Minimum PHP **7.4** (7.2/7.3 dropped — sites still on them stay pinned to 6.9.x). No multisite assumed. **7.1 makes the post editor iframe unconditional — see below.** | | **PHP** | **8.3** recommended | 7.4 = minimum. 8.4 / 8.5 = "beta support" (possible deprecation notices). 8.2 fully compatible but no longer the recommended default. | | **Elementor (free + Pro)** | **4.2+** | Separate plugins with **independent version numbers** — currently **free 4.2.3 / Pro 4.2.2** (both Aug 19, 2026). 4.0.0 (Mar 30, 2026) made the Atomic Editor stable + default for new installs; 4.2.0 (Jul 20) added Atomic Grid (free) and Atomic Loop (Pro). ⚠️ Elementor's header still reads **"Tested up to: 7.0.4"** — it has **not** declared WP 7.1 support. **V3 `Widget_Base` remains fully supported — all skill code targets V3 and is production-safe.** | | **WooCommerce** | **11.0+** | **11.0 (Aug 4, 2026) REMOVED the product editor beta** and bumped to **Action Scheduler 4.0.0** (breaking dedup change). HPOS default-on since 8.2; 10.7 disabled HPOS "sync on read"; 10.9 defers Store API draft-order creation — see woocommerce.md. | **Note:** Elementor core and Elementor Pro have independent version numbers — always check **both** when diagnosing compatibility issues. ### WordPress 7.1 — what changed for plugin / Elementor devs WP 7.1 **"Mary Lou"** shipped **August 19, 2026** (WordCamp US). Unlike 7.0, this release contains **four changes that can break a shipped plugin** — they are listed first. No PHP or WordPress minimum changed. **🔴 Breaking — audit your plugin for these:** 1. **The post editor is now ALWAYS in an iframe** — including on sites registering legacy meta boxes, which was the last remaining escape hatch. Editor JavaScript that reaches for the global `document` / `window` now targets the **wrong document**. Use the canvas element's **`ownerDocument`** and **`defaultView`** instead: ```js // ❌ breaks in 7.1 — this is the OUTER document, not the editor canvas document.querySelector( '.my-block' ).classList.add( 'ready' ); // ✅ resolve the document from a node you already own inside the canvas const doc = myCanvasNode.ownerDocument; const view = doc.defaultView; // the iframe's `window` doc.querySelector( '.my-block' )?.classList.add( 'ready' ); view.requestAnimationFrame( … ); ``` Injected `<style>`/`<link>` must go into the **canvas** document too, or it styles nothing. **Elementor's own editor is unaffected** (it is not the block editor) — this hits block registrations, meta boxes, and any block-editor integration your plugin ships. 2. **`__next40pxDefaultSize` is now a no-op** on `@wordpress/components` — **remove the prop** entirely; there is no replacement. Form controls render at 40px unconditionally. 3. **List-table markup changed:** the row header moved from the checkbox column to the **title** column. Any CSS/JS selector keyed on `th.check-column` (or assuming the row header's position) breaks — audit custom admin list tables and column callbacks. 4. **jQuery UI updated to 1.14.2.** Test anything depending on jQuery UI behaviour or styling. **🟢 New and useful:** - **SVG Icon API** (public in 7.1) — register icons and whole collections, render server-side, read over REST: `wp_register_icon_collection()`, `wp_register_icon()`, `wp_get_icon()`. ⚠️ SVGs pass through a **conservative allowlist — only `<svg>`, `<path>` and `<polygon>` survive**. A `<g>`, `<circle>`, `<rect>` or `<use>` in your icon is stripped silently, so convert shapes to paths before registering. (This is a *core* API — Elementor widgets still ship their own SVGs; see `elementor-extending.md` §8 and the "don't `wp_kses()` inline SVG" rule in `field-notes.md` §6.) - **Abilities API matured:** `wp_get_abilities()` filtering, **execution-lifecycle hooks**, custom validation, a unified `public` exposure flag, and client-compatible JSON-Schema preparation. Registration timing rules from 7.0 are unchanged (see the 7.0 section below). - **Global Styles / `theme.json`:** responsive style variations with **configurable breakpoints**, pseudo-state styling (`:hover` / `:focus` / `:active`), and `text-shadow` support. - **Media:** client-side image processing in the browser via **WebAssembly** (compression/resizing no longer hit the server), infinite scroll in the Media Library, and registration of multiple image sizes at once. Multisite now enforces upload limits on **media sideloading**. - **Persistent admin bar** across editor screens — review any custom toolbar nodes for behaviour in the editor context. - **Design System:** semantic design tokens for theming admin UI (relevant if you build admin or editor panels — compare the Elementor panel tokens in `elementor-extending.md` §5). - **DataViews / DataForm** APIs matured for data-driven admin interfaces. - **React stays on 18.3** — React 19 was deferred again. Don't bundle `react/jsx-runtime` yourself, and avoid string refs / `defaultProps`, which fail under the experimental React 19 flag. - Conditional block-CSS loading can affect **remote content pulls** — verify if you render blocks outside a normal page request. _Sources: wordpress.org/news/2026/08/mary-lou/ · make.wordpress.org/core/2026/08/05/wordpress-7-1-field-guide/ · developer.wordpress.org/news/2026/08/whats-new-for-developers-august-2026/_ ### WordPress 7.0 — what changed for plugin / Elementor devs WP 7.0 "Armstrong" shipped **May 20, 2026** (delayed from the original April 9 target while the RTC storage layer was redesigned — see below). Everything below is **opt-in and non-breaking**; most plugin/Elementor work is unaffected. It remains relevant because 7.1 changed none of it. - **Minimum PHP is now 7.4** (7.2/7.3 dropped). The skill's recommended baseline stays **PHP 8.3**. Bump your plugin's `Requires PHP` header to 7.4 only once you target WP 7.0+ exclusively. No new DB minimum is enforced; `wordpress.org/about/requirements/` recommends **MariaDB 10.11+ or MySQL 8.0+** (the MariaDB floor was raised from 10.6; re-verified Aug 2026). - **Real-Time Collaboration (RTC):** simultaneous multi-author block editing (CRDT-based, via an HTTP-polling sync provider — not WebRTC). Data is stored in a **dedicated core database table**; an earlier `wp_post_meta` / `wp_sync_storage` design was rejected, and building the table is what pushed the release from April to May. **Plugin impact:** scope every `WP_Query` / `get_posts()` with an explicit `post_type` so internal core post types never leak into your results — do **not** hardcode any internal RTC type name. The `WP_ALLOW_COLLABORATION` constant lets hosts swap the sync transport. - **WP AI Client:** provider-agnostic PHP + JS AI API — `wp_ai_client_prompt( $prompt )->generate_text()`. Guard with `function_exists( 'wp_ai_client_prompt' )`. - **Abilities API:** `wp_register_ability()` (PHP, since WP 6.9) plus a JS counterpart in 7.0. Use `'meta' => ['show_in_rest' => true]` to expose via REST. Register ONLY inside the **`wp_abilities_api_init`** hook (core renamed the feature plugin's `abilities_api_init`; categories go on `wp_abilities_api_categories_init`) or you get `_doing_it_wrong`; a `category` is required and must already be registered. Calling it also sets your real `Requires at least` floor to **6.9** — Plugin Check errors on the header and `function_exists()` guards don't count (see `debugging.md` §1). - **Connectors UI** (Settings → Connectors) for managing AI provider credentials, and a **Command Palette** in wp-admin. - **Iframed editor** was still conditional in 7.0 — **7.1 makes it unconditional.** See the 7.1 section above; `"apiVersion": 3` in `block.json` is now table stakes, not preparation. _Sources: make.wordpress.org/core/2026/01/09/dropping-support-for-php-7-2-and-7-3/ · make.wordpress.org/core/2026/04/22/wordpress-7-0-release-party-updated-schedule/ · wordpress.org/about/requirements/_ > 📌 **PHP support labels (unchanged in WP 7.1):** PHP 7.4–8.3 fully compatible; **8.3 recommended**; > 8.4 (WP 6.7+) and 8.5 (WP 6.9+) carry a "beta support" label (possible deprecation notices). > Source: make.wordpress.org/core/handbook/references/php-compatibility-and-wordpress-versions/ > ✅ **Elementor 4.x status (current: free 4.2.3 / Pro 4.2.2, both Aug 19, 2026):** Elementor > 4.0.0 (Mar 30, 2026, free + Pro) made the **Atomic Editor stable and the default for new > installs** and added Atomic Forms, Pro Interactions, and Component creation. Updating to 4.x > leaves **existing sites untouched** — V3 widgets and V4 Atomic Elements coexist on the same > page; Atomic features are toggled at WP Admin → Elementor → Editor → Settings. > > **There is still NO third-party API for building Atomic Elements, and Elementor has said so > explicitly** — they will not release one soon and *advise against* integrating with Atomic > internals until they announce it (elementor/elementor GitHub Discussion #32950). Atomic > Elements are documented only as a **data structure** > (developers.elementor.com/docs/data-structure/atomic-elements). So **continue using V3 > `Widget_Base`** for all third-party widgets — it is the correct, production-safe API and all > skill code targets it. *(Re-verified Aug 23, 2026 — unchanged since the July check.)* > > ⚠️ **Elementor has not declared WP 7.1 compatibility yet.** As of free 4.2.3 / Pro 4.2.2 the > `Tested up to` header reads **7.0.4**, three days after WP 7.1 shipped. That is normal lag, > not a known incompatibility — but when a client reports editor breakage on a fresh 7.1 site, > check Elementor's current `Tested up to` before debugging your own code. > > **V4 Atomic Elements that now ship by default (awareness only — not third-party-buildable yet):** > Div Block & Flexbox Container (layout); **Atomic Grid** — advanced row/column layouts (free > 4.2.0, Jul 20, 2026); Atomic Heading, Paragraph, Image, Button, Video, SVG; > **Atomic Tabs**; **Atomic Forms** (Pro) with composable fields — Label, Input, Textarea, > Checkbox, Submit, plus Radio, Select, Date Picker, Time Picker, and File Upload (added in > Pro 4.1.0, May 26, 2026); and **Atomic Loop** — dynamic content layouts, the V4 counterpart > of the Pro Loop Grid (Pro 4.2.0, Jul 20, 2026). These are end-user elements; building > **custom** atomic elements still awaits the finalized V4 extension docs — keep targeting V3 > `Widget_Base` until then. > Source: elementor.com/products/website-builder/v4-faq/ · > developers.elementor.com/elementor-editor-4-0-developers-update/ · > elementor.com/pro/changelog/ · github.com/elementor/elementor/releases > 🗓️ **Release-by-release history (betas, RCs, point releases) lives in `CHANGELOG.md`.** > Keep this router focused on durable guidance; update version facts in the table above > and in `CHANGELOG.md`, not scattered across the sub-files. --- ## 2. Architecture Decision Tree Run through this mentally before writing a single line: ``` Does a WordPress core hook (add_action/add_filter) solve it? YES → Use the hook. No plugin needed. Place in child theme functions.php or Elementor Custom Code. NO → Does Elementor's PHP/JS API solve it? YES → Extend via Elementor hooks, Dynamic Tags, or Widget_Base. Register via elementor/widgets/register or elementor/dynamic_tags/register. NO → Is this logic reusable across themes or sites? YES → Scaffold a dedicated plugin (see scaffolding.md). NO → Child theme functions.php or Elementor Custom Code block. Is a WooCommerce override needed? → Use Elementor Loop Grid + custom query filter BEFORE touching template files. → Only override woocommerce/ templates as an absolute last resort. Is this a REST API endpoint? → Always register via register_rest_route() inside a plugin, never in functions.php. ``` --- ## 3. Mandatory Output Format **Every single code response must follow this structure — no exceptions:** ``` 📍 PLACEMENT Exact file path or Elementor hook location. e.g. /wp-content/plugins/myplugin/includes/class-myplugin-hooks.php Elementor → Site Settings → Custom Code → wp_footer ⚙️ REQUIRES WordPress X.X+ | PHP X.X+ | Elementor Pro X.X+ | ACF X.X+ | WooCommerce X.X+ (list only what the code actually depends on) 💡 WHY THIS APPROACH One paragraph: which branch of the §2 decision tree was taken and why. 📋 CODE Complete, commented, deployment-ready code block — no truncation, no omissions. 🔧 INTEGRATION NOTES (include when relevant) Any manual steps required: flush rewrite rules, set Query ID in Elementor editor, activate plugin, clear Elementor cache, etc. ``` --- ## 4. Quick Reference — Pattern Index | Task | Approach | Sub-file | |---|---|---| | Plugin scaffold | Singleton + hooks class + assets class + HPOS declaration | scaffolding.md | | Child theme setup | `wp_enqueue_style` parent + child in functions.php | scaffolding.md | | Custom Post Type + Taxonomy | `register_post_type()` + `register_taxonomy()` in plugin | scaffolding.md | | Secure AJAX handler | `wp_ajax_` hooks + nonce verify + `wp_send_json_*` | scaffolding.md | | PHP sanitization / escaping | `wp_unslash()` + `sanitize_*` + `esc_*` patterns | php-standards.md | | Transient caching | `get_transient` / `set_transient` | php-standards.md | | External API call + WP_Error | `wp_remote_get()` + `WP_Error` pattern | php-standards.md | | Admin settings page | Settings API + `register_setting` + `sanitize_callback` + `settings_fields` | wordpress-apis.md | | Store plugin options | Options API + explicit boolean `autoload` (WP 6.6+) | wordpress-apis.md | | Custom field exposed to REST / Elementor | `register_post_meta` + `show_in_rest` | wordpress-apis.md | | Scheduled / background task | WP-Cron (`wp_schedule_event`) + Action Scheduler for heavy jobs | wordpress-apis.md | | Create a custom DB table | `dbDelta()` + `$wpdb->prefix` + `get_charset_collate()`; strict formatting rules | mariadb.md | | "Specified key was too long" / emoji not saving | `utf8mb4` + index string columns at **`varchar(191)`** | mariadb.md | | Slow query / missing index | `EXPLAIN` (`type: ALL`, `key: NULL`, `Using filesort`) + leftmost-prefix rule | mariadb.md | | SQL that must run on MariaDB **and** MySQL | Plain standard SQL; JSON is stored/compared differently — don't query inside it | mariadb.md | | Make a plugin translatable | i18n functions + text-domain = slug + WP 6.7 `init`-timing rule | wordpress-apis.md | | Ship a translation for a wp.org-hosted plugin | `.pot` only in the zip; translate.wordpress.org → language packs (90% gate). **Never** bundle `.mo` / call `load_plugin_textdomain()` | wordpress-apis.md | | Style UI text for non-Latin locales | Caseless scripts + `text-transform`/`letter-spacing`; don't size to English string length | js-css-standards.md | | Test that translations actually load | Set the site locale before bootstrap; `unload_textdomain()` in WP-CLI; validate placeholders | debugging.md | | Deploy / tag a plugin release on wp.org SVN | `svn co` → copy to `trunk/` → `svn cp trunk tags/X.Y.Z` → `svn ci` (assets/ is a sibling) | svn/svn.md | | Any SVN task (checkout, commit, branch, merge, conflicts, repo admin) | Subversion sub-bundle | svn/svn.md | | WP 6.8 password hashing | `wp_check_password()` + `wp_password_needs_rehash()` | php-standards.md | | WP 6.8 app password / key hashing | `wp_fast_hash()` + `wp_verify_fast_hash()` (BLAKE2b) | php-standards.md | | JS standards + enqueue defer/async | IIFE + WP 6.3+ enqueue API | js-css-standards.md | | PHP → JS data passing | `wp_add_inline_script()` with `wp_json_encode()` | js-css-standards.md | | CSS BEM + design tokens | Scoped tokens, 8pt spacing, fluid type | js-css-standards.md | | Custom Elementor widget | `Widget_Base` + all required methods | elementor-patterns.md | | Elementor Dynamic Tag | `Tag` class + `elementor/dynamic_tags/register` | elementor-patterns.md | | Elementor Loop Grid query | `elementor/query/` filter | elementor-patterns.md | | ACF field in Elementor | Dynamic Tag extending `\Elementor\Core\DynamicTags\Tag` | elementor-patterns.md | | Elementor Pro Form action | `Action_Base` + field iteration | elementor-patterns.md | | Theme Builder custom condition | `Condition_Base` + `elementor/theme/register_conditions` | elementor-patterns.md | | Elementor Pro custom form field | `Field_Base` + `elementor_pro/forms/fields/register` | elementor-extending.md | | Theme Builder custom location | `register_location` + `elementor_theme_do_location()` | elementor-extending.md | | Add a control to a NATIVE Elementor widget | `elementor/element/{el}/{section}/before_section_end` | elementor-extending.md | | Filter a native widget's output | `elementor/widget/render_content` filter | elementor-extending.md | | Add a TAB to the editor panel (like an SEO plugin) | `elementorV2.editorElementsPanel.injectTab()` + `elementor/editor/before_enqueue_scripts` | elementor-extending.md | | WooCommerce HPOS compatibility | `FeaturesUtil::declare_compatibility()` + `wc_get_order()` | woocommerce.md | | WooCommerce loop | Loop Grid + `elementor/query/` filter | woocommerce.md | | Custom REST endpoint | `register_rest_route()` + schema callback in plugin | rest-api.md | | Off-canvas filter panel | BEM CSS + ARIA JS + Custom Code block | offcanvas-ui.md | | Performance checklist | Frontend + backend checklists | performance.md | | Accessibility checklist | WCAG 2.2 AA patterns | performance.md | | Elementor dependency check | `Requires Plugins: elementor` header + `did_action` fallback | scaffolding.md | | Widget with button/CTA | Controls + render matching native Button widget | widget-button.md | | Widget with container/layout | Controls + render matching native Container | widget-container.md | | Widget with image | `Group_Control_Image_Size` + `get_attachment_image_html()` | widget-image.md | | Widget with heading | TEXTAREA + header_size tag selector | widget-heading.md | | Widget with rich text | WYSIWYG + wp_kses_post output | widget-text-editor.md | | Widget with video embed | Source SELECT + overlay + aspect ratio | widget-video.md | | Widget rendering saved Elementor template | get_builder_content_for_display() + SELECT2 | widget-elementor-template.md | | Widget with PHP template file | load_template() + locate_template() strategies A/B/C | widget-php-template.md | | Widget with divider line | Style + width + optional text/icon element | widget-divider.md | | Widget with spacer gap | Single responsive SLIDER | widget-spacer.md | | Widget with single icon | ICONS control + size + color tabs | widget-icon.md | | Widget with icon + text box | Icon + title + description pattern | widget-icon-box.md | | Widget with image + text box | Image + title + description pattern | widget-image-box.md | | Widget with image grid | GALLERY control + Group_Control_Image_Size | widget-image-gallery.md | | Widget with image slider | GALLERY + Swiper + navigation controls | widget-image-carousel.md | | Widget with icon bullet list | REPEATER + icon + text + optional link | widget-icon-list.md | | Widget with animated counter | Number + prefix/suffix + duration | widget-counter.md | | Widget with progress bar | Percentage SLIDER + bar styling | widget-progress.md | | Widget with testimonial quote | Content + image + name + job title | widget-testimonial.md | | Widget with tabbed panels | REPEATER tabs + horizontal/vertical type | widget-tabs.md | | Widget with accordion | REPEATER + single-open collapse pattern | widget-accordion.md | | Widget with toggle panels | REPEATER + multi-open toggle pattern | widget-toggle.md | | Widget with social icons | REPEATER + brand icons + links | widget-social-icons.md | | Widget with alert/notice box | Type SELECT + title + description + dismiss | widget-alert.md | | Widget with audio player | SoundCloud URL + autoplay options | widget-audio.md | | Widget outputting shortcode | TEXTAREA + do_shortcode() | widget-shortcode.md | | Widget with raw HTML embed | CODE control + unescaped output | widget-html.md | | Widget as named anchor | TEXT ID + sanitize_html_class() | widget-menu-anchor.md | | Widget outputting sidebar | Registered sidebar SELECT + dynamic_sidebar() | widget-sidebar.md | | Widget with read more tag | No controls — WordPress $more global | widget-read-more.md | | Widget with Google Maps | Address TEXT + zoom SLIDER + iframe | widget-google-maps.md | | Widget with star rating display | Scale + rating number + icon style | widget-star-rating.md | | Widget with schema rating | Icon count + fractional rating + gap | widget-rating.md | | Widget with curved/path text | `<svg>` + `<textPath>` + unique path id | widget-text-path.md | | Nested Tabs / Accordion widget | `Widget_Nested_Base` + `print_child()` + container panels | widget-nested.md | --- ## 5. Mandatory Widget Controls — No Hardcoded Visuals (NEVER VIOLATE) > **This section is MANDATORY for every custom Elementor widget.** Whenever you build a widget, > every visual property must be an Elementor control — NEVER a hardcoded CSS value. Users > control appearance from the editor panel/toolbar, not by editing source code. ### The Rule **NEVER hardcode** any of the following in PHP `render()`, in static CSS, or in `content_template()` output: - Colors (text, background, border, shadow) - Typography (font family, size, weight, line-height, letter-spacing, transform) - Spacing (padding, margin, gap) - Sizing (width, height, min/max values) - Borders (style, width, color, radius) - Shadows (box-shadow, text-shadow) - Backgrounds (color, gradient, image) - Alignment / positioning - Opacity, transitions, hover effects **ALL** of the above must use Elementor controls with `selectors` that inject CSS dynamically. The only exceptions are structural CSS (display, position, overflow) required for the widget layout to function at all — and even these should use controls when there is a user-facing choice (e.g. flex-direction toggle). > ⚠️ **"No hardcoded visuals" does NOT mean "put a `default` on every control."** > Elementor emits a `default` exactly like a user-set value, so a colour default paints on every > install and clearing the swatch only restores it — the user cannot turn it off. **Elementor core > carries no `default` on colour controls at all** (`includes/widgets/heading.php`: `title_color` > uses a `'global'` reference; `title_hover_color` is entirely empty). Follow that split: > - **Sizes, spacing, alignment, toggles, tags, labels** → give a real `'default'`. > - **Colours** → leave empty. Put the resting look in your stylesheet's `var(--token, fallback)` > and let the control override it. For **state** colours (hover / active / selected / current) > also write a **direct CSS property**, never a custom property, and keep that state > **colour-free in your CSS** — otherwise "empty" still paints the fallback. > > See `field-notes.md` §4 for the full trap, plus the theme-specificity and `accent-color` cases. ### Required Controls Checklist — Apply to Every Widget When building a widget, include ALL controls that apply to its visual elements. Use this checklist as a mandatory gate: | Visual property | Required Elementor control | Tab | |---|---|---| | **Text content** | `TEXT`, `TEXTAREA`, or `WYSIWYG` + `'dynamic' => ['active' => true]` | TAB_CONTENT | | **Typography** (any text element) | `add_group_control( Group_Control_Typography::get_type() )` | TAB_STYLE | | **Text color** | `COLOR` control with `selectors` | TAB_STYLE | | **Text alignment** | `add_responsive_control()` with `CHOOSE` (left/center/right/justify) | TAB_STYLE or TAB_CONTENT | | **Background** | `add_group_control( Group_Control_Background::get_type() )` | TAB_STYLE | | **Border** | `add_group_control( Group_Control_Border::get_type() )` | TAB_STYLE | | **Border radius** | `add_responsive_control()` with `DIMENSIONS` + `'selectors'` | TAB_STYLE | | **Box shadow** | `add_group_control( Group_Control_Box_Shadow::get_type() )` | TAB_STYLE | | **Text shadow** | `add_group_control( Group_Control_Text_Shadow::get_type() )` | TAB_STYLE | | **Padding** | `add_responsive_control()` with `DIMENSIONS` | TAB_STYLE | | **Margin** | `add_responsive_control()` with `DIMENSIONS` | TAB_STYLE | | **Width / Height** | `add_responsive_control()` with `SLIDER` | TAB_STYLE | | **Spacing / Gap** | `add_responsive_control()` with `SLIDER` | TAB_STYLE | | **Image** | `MEDIA` + `add_group_control( Group_Control_Image_Size::get_type() )` | TAB_CONTENT | | **CSS Filters** (if image/element) | `add_group_control( Group_Control_Css_Filter::get_type() )` | TAB_STYLE | | **Hover state** | Duplicate color/background/shadow controls inside `'section_style_hover'` with `selectors` targeting `:hover` | TAB_STYLE | | **Transition duration** | `SLIDER` (seconds) with `selectors => ['transition-duration']` | TAB_STYLE | | **Link** | `URL` control with `'dynamic' => ['active' => true]` | TAB_CONTENT | | **Icon** | `ICONS` control with `fa4compatibility` | TAB_CONTENT | | **HTML tag** | `SELECT` (h1–h6, div, span, p) | TAB_CONTENT | ### Example — Correct vs Incorrect ```php // ❌ WRONG — hardcoded color and font-size protected function render(): void { $settings = $this->get_settings_for_display(); echo '<h2 style="color: #e94560; font-size: 24px;">' . esc_html( $settings['title'] ) . '</h2>'; } // ✅ CORRECT — all visuals controlled from the panel via selectors protected function register_controls(): void { // ... Content section with title TEXT control ... $this->start_controls_section( 'section_title_style', [ 'label' => esc_html__( 'Title Style', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-widget__title' => 'color: {{VALUE}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'selector' => '{{WRAPPER}} .myplugin-widget__title', ] ); $this->add_responsive_control( 'title_align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-widget__title' => 'text-align: {{VALUE}};', ], ] ); $this->add_responsive_control( 'title_spacing', [ 'label' => esc_html__( 'Bottom Spacing', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'size_units' => [ 'px', 'em', 'rem' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'selectors' => [ '{{WRAPPER}} .myplugin-widget__title' => 'margin-bottom: {{SIZE}}{{UNIT}};', ], ] ); $this->end_controls_section(); } protected function render(): void { $settings = $this->get_settings_for_display(); // ✅ No inline styles — all visuals come from Elementor's selectors $this->add_render_attribute( 'title', 'class', 'myplugin-widget__title' ); $this->add_inline_editing_attributes( 'title' ); echo '<h2 ' . $this->get_render_attribute_string( 'title' ) . '>' . esc_html( $settings['title'] ) . '</h2>'; } ``` ### `add_render_attribute()` and `add_inline_editing_attributes()` — Always Use The official Elementor API for building HTML attributes is `$this->add_render_attribute()`. **Always use it** instead of manually concatenating class/id/aria attributes in `render()`. Pair with `$this->add_inline_editing_attributes()` for any text field that supports live editing in the Elementor editor panel. **Outputting the built attributes — pick by context:** use `get_render_attribute_string( 'key' )` when you are concatenating into a string (as in the `echo '<h2 ' . ... . '>'` example above), and `print_render_attribute_string( 'key' )` when you are echoing directly inside a `?> … <?php` HTML block (e.g. `<h2 <?php $this->print_render_attribute_string( 'title' ); ?>>`). Both are correct Elementor APIs — `print_*` simply echoes what `get_*` returns. The widget sub-files use the `print_*` form inside their HTML templates. Source: developers.elementor.com/docs/widgets/rendering-html-attribute/ Source: developers.elementor.com/docs/widgets/rendering-inline-editing/ -
widget-accordion.md 14.5 KB
# Widget Boilerplate — Accordion > **When to use this file:** Load whenever building a collapsible content widget where one panel opens at a time. > Verified against `elementor/includes/widgets/accordion.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > Toggle widget is identical except `multiple_active` defaults to 'yes' — note at bottom. --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_title', [ 'label' => esc_html__( 'Accordion', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $repeater = new \Elementor\Repeater(); $repeater->add_control( 'tab_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Accordion Title', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $repeater->add_control( 'tab_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'type' => \Elementor\Controls_Manager::WYSIWYG, 'default' => esc_html__( 'Accordion Content', 'myplugin' ), 'show_label' => false, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'tabs', [ 'label' => esc_html__( 'Items', 'myplugin' ), 'type' => \Elementor\Controls_Manager::REPEATER, 'fields' => $repeater->get_controls(), 'default' => [ [ 'tab_title' => esc_html__( 'Accordion #1', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Accordion #2', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Accordion #3', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ], ], 'title_field' => '{{{ tab_title }}}', ] ); $this->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'separator' => 'before', 'fa4compatibility' => 'icon', 'default' => [ 'value' => 'fas fa-plus', 'library' => 'fa-solid' ], 'label_block' => false, 'skin' => 'inline', ] ); $this->add_control( 'selected_active_icon', [ 'label' => esc_html__( 'Active Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'fa4compatibility' => 'icon_active', 'default' => [ 'value' => 'fas fa-minus', 'library' => 'fa-solid' ], 'label_block' => false, 'skin' => 'inline', 'condition' => [ 'selected_icon[value]!' => '' ], ] ); $this->add_control( 'title_html_tag', [ 'label' => esc_html__( 'Title HTML Tag', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ 'p' => 'p', 'div' => 'div', 'span' => 'span', 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6' ], 'default' => 'p', 'separator' => 'before', ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_title_style', [ 'label' => esc_html__( 'Accordion', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'border_width', [ 'label' => esc_html__( 'Border Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 10 ] ], 'default' => [ 'size' => 1, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-accordion-item, {{WRAPPER}} .myplugin-tab-title, {{WRAPPER}} .myplugin-tab-content' => 'border-width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_control( 'border_color', [ 'label' => esc_html__( 'Border Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-accordion-item, {{WRAPPER}} .myplugin-tab-title, {{WRAPPER}} .myplugin-tab-content' => 'border-color: {{VALUE}};', ], ] ); $this->end_controls_section(); $this->start_controls_section( 'section_toggle_style_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'title_background', [ 'label' => esc_html__( 'Background', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-tab-title' => 'background-color: {{VALUE}};' ], ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-tab-title' => 'color: {{VALUE}};' ], 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], ] ); $this->add_control( 'title_active_color', [ 'label' => esc_html__( 'Active Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-tab-title.myplugin-active' => 'color: {{VALUE}};' ], 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_ACCENT ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'selector' => '{{WRAPPER}} .myplugin-tab-title', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], ] ); $this->end_controls_section(); $this->start_controls_section( 'section_toggle_style_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'content_background_color', [ 'label' => esc_html__( 'Background', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-tab-content' => 'background-color: {{VALUE}};' ], ] ); $this->add_control( 'content_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-tab-content' => 'color: {{VALUE}};' ], 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'content_typography', 'selector' => '{{WRAPPER}} .myplugin-tab-content', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], ] ); $this->end_controls_section(); } ``` > ⚠️ **Keyboard interaction requirement (ARIA APG §3.1):** Elements with `role="button"` that > are NOT native `<button>` elements do NOT receive keyboard events for free. Your widget JS > **MUST** handle both `click` and `keydown` (Enter + Space) to activate panels, and `keydown` > (Up/Down arrows) to move focus between panel titles. Without this, keyboard-only users cannot > operate the accordion. > ```js > // Minimal keyboard handler — add inside your elementor/frontend/init addAction callback > scope.querySelectorAll( '.myplugin-tab-title' ).forEach( el => { > el.addEventListener( 'keydown', e => { > if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); el.click(); } > } ); > } ); > ``` > Source: w3.org/WAI/ARIA/apg/patterns/accordion/ > **Toggle widget note:** Toggle is identical to Accordion with two differences: > 1. `get_name()` returns `'toggle'`, `get_title()` returns `'Toggle'` > 2. All panels can be open simultaneously (no mutual exclusion in JS) > **Remove checklist:** > - `selected_icon` + `selected_active_icon` → remove if no expand/collapse icons needed > - `title_html_tag` → remove if title tag is always fixed **render() + content_template() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $tabs = $settings['tabs']; if ( empty( $tabs ) ) { return; } // ✅ get_id() replaces deprecated get_id_int() (deprecated since Elementor 3.1.0) // Source: developers.elementor.com/v3-5-planned-deprecations/ $id_int = substr( $this->get_id(), 0, 3 ); ?> <div class="myplugin-accordion"> <?php foreach ( $tabs as $index => $tab ) : $tab_count = $index + 1; $tab_id = 'myplugin-accordion-' . $id_int . $tab_count; $tag = \Elementor\Utils::validate_html_tag( $settings['title_html_tag'] ); $is_active = 1 === $tab_count; ?> <?php // ✅ aria-controls must point to the content panel ID — required by ARIA APG Accordion pattern. // Without it, screen readers announce expand/collapse state but cannot navigate to the region. // Source: w3.org/WAI/ARIA/apg/patterns/accordion/ $content_id = 'myplugin-content-' . $id_int . $tab_count; ?> <div class="myplugin-accordion-item elementor-repeater-item-<?php echo esc_attr( $tab['_id'] ); ?>"> <<?php echo esc_attr( $tag ); ?> id="<?php echo esc_attr( $tab_id ); ?>" class="myplugin-tab-title<?php echo $is_active ? ' myplugin-active' : ''; ?>" aria-expanded="<?php echo $is_active ? 'true' : 'false'; ?>" aria-controls="<?php echo esc_attr( $content_id ); ?>" role="button" tabindex="0"> <?php // ✅ BUG FIX: selected_icon and selected_active_icon are WIDGET-LEVEL controls, // not per-item repeater controls. Check $settings, not $tab. // Using $tab['selected_icon'] would always be empty — it's not in the repeater. if ( ! empty( $settings['selected_icon']['value'] ) ) : ?> <span class="myplugin-accordion-icon myplugin-accordion-icon--<?php echo $is_active ? 'opened' : 'closed'; ?>"> <span class="myplugin-accordion-icon-opened"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_active_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> <span class="myplugin-accordion-icon-closed"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> </span> <?php endif; ?> <span class="myplugin-accordion-title"><?php echo esc_html( $tab['tab_title'] ); ?></span> </<?php echo esc_attr( $tag ); ?>> <div id="<?php echo esc_attr( $content_id ); ?>" class="myplugin-tab-content<?php echo $is_active ? ' myplugin-active' : ''; ?>" role="region" aria-labelledby="<?php echo esc_attr( $tab_id ); ?>"> <?php echo wp_kses_post( $tab['tab_content'] ); ?> </div> </div> <?php endforeach; ?> </div> <?php } protected function content_template(): void { ?> <# if ( ! settings.tabs || ! settings.tabs.length ) { return; } var tag = elementor.helpers.validateHTMLTag( settings.title_html_tag ); #> <div class="myplugin-accordion"> <# _.each( settings.tabs, function( tab, index ) { var isActive = 0 === index; var tabId = 'myplugin-accordion-' + index; var contentId = 'myplugin-content-' + index; // ✅ BUG FIX: Both icons are WIDGET-LEVEL controls (settings.*), not per-item (tab.*). // tab.selected_icon does not exist — it is not in the repeater fields. // Read settings.selected_icon for the closed icon, settings.selected_active_icon for open. var iconHTML = settings.selected_icon && settings.selected_icon.value ? elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' ) : null; var activeIconHTML = settings.selected_active_icon && settings.selected_active_icon.value ? elementor.helpers.renderIcon( view, settings.selected_active_icon, { 'aria-hidden': true }, 'i', 'object' ) : null; #> <div class="myplugin-accordion-item"> <{{{ tag }}} id="{{ tabId }}" class="myplugin-tab-title{{ isActive ? ' myplugin-active' : '' }}" aria-expanded="{{ isActive ? 'true' : 'false' }}" aria-controls="{{ contentId }}" role="button" tabindex="0"> <# if ( iconHTML ) { #> <span class="myplugin-accordion-icon"> <span class="myplugin-accordion-icon-opened">{{{ activeIconHTML ? activeIconHTML.value : '' }}}</span> <span class="myplugin-accordion-icon-closed">{{{ iconHTML.value }}}</span> </span> <# } #> <span class="myplugin-accordion-title">{{ tab.tab_title }}</span> </{{{ tag }}}> <div id="{{ contentId }}" class="myplugin-tab-content{{ isActive ? ' myplugin-active' : '' }}" role="region" aria-labelledby="{{ tabId }}"> {{{ tab.tab_content }}} </div> </div> <# } ); #> </div> <?php } ``` -
widget-alert.md 7.9 KB
# Widget Boilerplate — Alert > **When to use this file:** Load whenever building a colored notice/alert box widget. > Verified against `elementor/includes/widgets/alert.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_alert', [ 'label' => esc_html__( 'Alert', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'alert_type', [ 'label' => esc_html__( 'Type', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'info', 'options' => [ 'info' => esc_html__( 'Info', 'myplugin' ), 'success' => esc_html__( 'Success', 'myplugin' ), 'warning' => esc_html__( 'Warning', 'myplugin' ), 'danger' => esc_html__( 'Danger', 'myplugin' ), ], // ✅ The selected type drives a `.myplugin-alert--{type}` modifier class on the alert // element in render() (see below) + the default CSS at the bottom of this file. // Do NOT use prefix_class => 'elementor-alert-': that writes the class to the widget // WRAPPER and depends on Elementor's native alert CSS being loaded, which is NOT // guaranteed for a custom widget — the colors would silently never appear. ] ); $this->add_control( 'alert_title', [ 'label' => esc_html__( 'Title & Description', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'placeholder' => esc_html__( 'Your Title', 'myplugin' ), 'default' => esc_html__( 'This is an Alert', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'alert_description', [ 'label' => '', 'type' => \Elementor\Controls_Manager::TEXTAREA, 'default' => esc_html__( 'I am a description. Click the edit button to change this text.', 'myplugin' ), 'placeholder' => esc_html__( 'Your Description', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'show_dismiss', [ 'label' => esc_html__( 'Dismiss Button', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'hide', 'options' => [ 'show' => esc_html__( 'Show', 'myplugin' ), 'hide' => esc_html__( 'Hide', 'myplugin' ), ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_type_style', [ 'label' => esc_html__( 'Alert Box', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); // ✅ Background + Text color controls OVERRIDE the per-type CSS defaults — so visuals // remain fully user-controllable (SKILL.md §0 Golden Rule #6). Leave empty to keep the // type preset; set a value to override it. $this->add_control( 'background_color', [ 'label' => esc_html__( 'Background Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-alert' => 'background-color: {{VALUE}};' ], ] ); $this->add_control( 'alert_text_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-alert' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Border::get_type(), [ 'name' => 'alert_border', 'selector' => '{{WRAPPER}} .myplugin-alert' ] ); $this->add_responsive_control( 'border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-alert' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); // ✅ Type modifier class on the alert element itself — this is what makes the // info/success/warning/danger colors actually apply (see CSS block below). $type = ! empty( $settings['alert_type'] ) ? $settings['alert_type'] : 'info'; ?> <div class="myplugin-alert myplugin-alert--<?php echo esc_attr( $type ); ?>" role="alert"> <?php if ( 'show' === $settings['show_dismiss'] ) : ?> <button type="button" class="myplugin-alert-dismiss" aria-label="<?php esc_attr_e( 'Close', 'myplugin' ); ?>"> × </button> <?php endif; ?> <?php if ( ! empty( $settings['alert_title'] ) ) : ?> <span class="myplugin-alert-title"><?php echo esc_html( $settings['alert_title'] ); ?></span> <?php endif; ?> <?php if ( ! empty( $settings['alert_description'] ) ) : ?> <span class="myplugin-alert-description"><?php echo wp_kses_post( $settings['alert_description'] ); ?></span> <?php endif; ?> </div> <?php } ``` --- **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( settings.alert_title || settings.alert_description ) { var type = settings.alert_type || 'info'; #> <div class="myplugin-alert myplugin-alert--{{ type }}" role="alert"> <# if ( 'show' === settings.show_dismiss ) { #> <button type="button" class="myplugin-alert-dismiss">×</button> <# } #> <# if ( settings.alert_title ) { #> <span class="myplugin-alert-title">{{ settings.alert_title }}</span> <# } #> <# if ( settings.alert_description ) { #> <span class="myplugin-alert-description">{{ settings.alert_description }}</span> <# } #> </div> <# } #> <?php } ``` --- **Default styles** (`assets/css/myplugin-alert.css` — register it and declare via `get_style_depends()`; see `elementor-patterns.md` Step 2). These are sensible **defaults**; the Style-tab Background / Text / Border controls override them, so visuals stay user-controlled. ```css /* Baseline layout + per-type default colors (mirrors how Elementor's own alert ships presets). */ .myplugin-alert { position: relative; padding: 15px; border-left: 5px solid transparent; } .myplugin-alert--info { background: #d9edf7; border-color: #5bc0de; color: #31708f; } .myplugin-alert--success { background: #dff0d8; border-color: #5cb85c; color: #3c763d; } .myplugin-alert--warning { background: #fcf8e3; border-color: #f0ad4e; color: #8a6d3b; } .myplugin-alert--danger { background: #f2dede; border-color: #d9534f; color: #a94442; } .myplugin-alert-title { display: block; font-weight: 700; } .myplugin-alert-description { display: block; } .myplugin-alert-dismiss { position: absolute; top: 10px; inset-inline-end: 12px; padding: 0; background: none; border: 0; font-size: 18px; line-height: 1; color: inherit; cursor: pointer; } ``` -
widget-audio.md 3.5 KB
# Widget Boilerplate — Audio > **When to use this file:** Load whenever building a widget that embeds SoundCloud or self-hosted audio. > Verified against `elementor/includes/widgets/audio.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_audio', [ 'label' => esc_html__( 'Audio', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'placeholder' => esc_html__( 'Enter your SoundCloud URL', 'myplugin' ), 'default' => 'https://soundcloud.com/shawn-wasabi/maple-syrup', 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'visual', [ 'label' => esc_html__( 'Visual Player', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ] ); $this->add_control( 'sc_auto_play', [ 'label' => esc_html__( 'Autoplay', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', ] ); $this->end_controls_section(); } ``` --- **render() + content_template() skeleton:** ```php public function has_widget_inner_wrapper(): bool { return false; } protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['link'] ) ) { return; } // ✅ Use wp_oembed_get() to convert the SoundCloud URL into an embed iframe. // Falls back gracefully — returns false if oEmbed fails (e.g. private track). // ⚠️ Pass the RAW URL — do NOT wrap in esc_url() here. esc_url() HTML-encodes // ampersands (&→&) which corrupts the URL before wp_oembed_get() can parse it. // Sanitize with sanitize_url() instead, which strips unsafe characters without encoding. $oembed_html = wp_oembed_get( sanitize_url( $settings['link'] ) ); if ( ! $oembed_html ) { if ( \Elementor\Plugin::$instance->editor && \Elementor\Plugin::$instance->editor->is_edit_mode() ) { echo '<p>' . esc_html__( 'Could not load audio. Check the URL.', 'myplugin' ) . '</p>'; } return; } // ✅ DO NOT use wp_kses_post() on oEmbed HTML. // wp_kses_post() uses wp_kses_allowed_html('post') which does NOT include <iframe>. // SoundCloud oEmbed returns an <iframe> — wp_kses_post() would strip it entirely, // producing empty output with no error. wp_oembed_get() returns WordPress-generated // HTML that has already been processed through WordPress's own oEmbed stack (trusted). // This matches Elementor's native Audio widget (elementor/includes/widgets/audio.php). // Source: developer.wordpress.org/reference/functions/wp_kses_allowed_html/ ('post' context) echo '<div class="myplugin-audio">' . $oembed_html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted WordPress-generated oEmbed; wp_kses_post strips <iframe> } protected function content_template(): void { ?> <# if ( ! settings.link ) { return; } #> <div class="myplugin-audio myplugin-audio--placeholder"> <p><?php echo esc_html__( 'Audio preview available on the frontend.', 'myplugin' ); ?></p> </div> <?php } ``` -
widget-button.md 17.8 KB
# Widget Boilerplate — Button > **When to use this file:** Load this boilerplate whenever building a widget that has a > primary clickable call-to-action — a button, submit trigger, or any linked element with > text and optional icon. > > Verified against `elementor/includes/widgets/traits/button-trait.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > Control IDs, defaults, selectors, and order match Elementor's native Button widget exactly. --- ### Button Widget Boilerplate Verified against `elementor/includes/widgets/traits/button-trait.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). ```php protected function register_controls(): void { // ========================================================= // TAB: CONTENT // ========================================================= $this->start_controls_section( 'section_button', [ 'label' => esc_html__( 'Button', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); // Type — prefix_class writes e.g. 'elementor-button-info' on the widget wrapper. // Empty string = Default (no extra class added). $this->add_control( 'button_type', [ 'label' => esc_html__( 'Type', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => '', 'options' => [ '' => esc_html__( 'Default', 'myplugin' ), 'info' => esc_html__( 'Info', 'myplugin' ), 'success' => esc_html__( 'Success', 'myplugin' ), 'warning' => esc_html__( 'Warning', 'myplugin' ), 'danger' => esc_html__( 'Danger', 'myplugin' ), ], 'prefix_class' => 'elementor-button-', ] ); // ✅ Elementor native default is 'Click me' — match exactly $this->add_control( 'text', [ 'label' => esc_html__( 'Text', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Click me', 'myplugin' ), 'placeholder' => esc_html__( 'Click me', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'placeholder' => esc_html__( 'https://your-link.com', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'default' => [ 'value' => '', 'library' => '' ], 'label_block' => true, 'fa4compatibility' => 'icon', ] ); $this->add_control( 'icon_align', [ 'label' => esc_html__( 'Icon Position', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'left', 'options' => [ 'left' => esc_html__( 'Before', 'myplugin' ), 'right' => esc_html__( 'After', 'myplugin' ), ], 'condition' => [ 'selected_icon[value]!' => '' ], ] ); $this->add_control( 'icon_indent', [ 'label' => esc_html__( 'Icon Spacing', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 8, 'unit' => 'px' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 50 ] ], 'condition' => [ 'selected_icon[value]!' => '' ], 'selectors' => [ // ✅ These class names match Elementor's native button HTML output '{{WRAPPER}} .elementor-button .elementor-align-icon-right' => 'margin-left: {{SIZE}}{{UNIT}};', '{{WRAPPER}} .elementor-button .elementor-align-icon-left' => 'margin-right: {{SIZE}}{{UNIT}};', ], ] ); $this->add_control( 'button_id', [ 'label' => esc_html__( 'Button ID', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => '', 'description' => esc_html__( 'Please make sure the ID is unique and not used elsewhere on the page. This field allows A-z, 0-9 & underscore chars without spaces.', 'myplugin' ), 'separator' => 'before', 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); // ========================================================= // TAB: STYLE // ========================================================= $this->start_controls_section( 'section_style', [ 'label' => esc_html__( 'Button', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); // ✅ Elementor native uses prefix_class for alignment, not a CSS selector. // 'elementor%s-button-align-' resolves to e.g. 'elementor-button-align-center' // on desktop and 'elementor-tablet-button-align-center' on tablet. $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], 'justify' => [ 'title' => esc_html__( 'Justified', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ], ], 'prefix_class' => 'elementor%s-button-align-', // ✅ No 'default' — empty inherits document flow without writing inline style ] ); // ✅ Size — uses prefix_class so Elementor's own CSS defines the preset padding values $this->add_control( 'size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'sm', 'options' => [ 'xs' => esc_html__( 'Extra Small', 'myplugin' ), 'sm' => esc_html__( 'Small', 'myplugin' ), 'md' => esc_html__( 'Medium', 'myplugin' ), 'lg' => esc_html__( 'Large', 'myplugin' ), 'xl' => esc_html__( 'Extra Large', 'myplugin' ), ], 'prefix_class' => 'elementor-size-', 'style_transfer' => true, ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_ACCENT ], // ✅ Dual selector — targets both <a> and <button> render() output 'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button', ] ); // ✅ text_shadow comes BEFORE the Normal/Hover tabs — matches Elementor native order $this->add_group_control( \Elementor\Group_Control_Text_Shadow::get_type(), [ 'name' => 'text_shadow', 'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button', ] ); // ── Normal / Hover tabs ─────────────────────────────────── $this->start_controls_tabs( 'tabs_button_style' ); $this->start_controls_tab( 'tab_button_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ), ] ); $this->add_control( 'button_text_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', // ✅ Dual selector matches both <a> and <button> output 'selectors' => [ '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button' => 'fill: {{VALUE}}; color: {{VALUE}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Background::get_type(), [ 'name' => 'background', 'types' => [ 'classic', 'gradient' ], 'exclude' => [ 'image' ], 'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button', 'fields_options' => [ 'background' => [ 'default' => 'classic' ], ], ] ); $this->end_controls_tab(); $this->start_controls_tab( 'tab_button_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ), ] ); $this->add_control( 'hover_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} a.elementor-button:hover, {{WRAPPER}} .elementor-button:hover, {{WRAPPER}} a.elementor-button:focus, {{WRAPPER}} .elementor-button:focus' => 'color: {{VALUE}};', '{{WRAPPER}} a.elementor-button:hover svg, {{WRAPPER}} .elementor-button:hover svg' => 'fill: {{VALUE}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Background::get_type(), [ 'name' => 'button_background_hover', 'types' => [ 'classic', 'gradient' ], 'exclude' => [ 'image' ], 'selector' => '{{WRAPPER}} a.elementor-button:hover, {{WRAPPER}} .elementor-button:hover, {{WRAPPER}} a.elementor-button:focus, {{WRAPPER}} .elementor-button:focus', ] ); $this->add_control( 'button_hover_border_color', [ 'label' => esc_html__( 'Border Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'condition' => [ 'border_border!' => '' ], 'selectors' => [ '{{WRAPPER}} a.elementor-button:hover, {{WRAPPER}} .elementor-button:hover, {{WRAPPER}} a.elementor-button:focus, {{WRAPPER}} .elementor-button:focus' => 'border-color: {{VALUE}};', ], ] ); // ⛔ DO NOT include hover_animation by default. // It loads Elementor's animation CSS library on every page using this widget. // Only uncomment if the client explicitly requires animation support. // // $this->add_control( 'hover_animation', [ // 'label' => esc_html__( 'Hover Animation', 'myplugin' ), // 'type' => \Elementor\Controls_Manager::HOVER_ANIMATION, // ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->add_group_control( \Elementor\Group_Control_Border::get_type(), [ 'name' => 'border', 'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button', 'separator' => 'before', ] ); $this->add_responsive_control( 'border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Box_Shadow::get_type(), [ 'name' => 'button_box_shadow', 'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button', ] ); // ✅ Elementor native control ID is 'text_padding' not 'padding' $this->add_responsive_control( 'text_padding', [ 'label' => esc_html__( 'Padding', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], 'separator' => 'before', ] ); $this->end_controls_section(); } ``` **Matching render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['text'] ) && empty( $settings['selected_icon']['value'] ) ) { return; } $this->add_render_attribute( 'wrapper', 'class', 'elementor-button-wrapper' ); $this->add_render_attribute( 'button', [ 'class' => 'elementor-button', // ✅ Do NOT add role='button' here. When $tag resolves to <button>, the button // element already has an implicit role of 'button' per ARIA spec — adding it // explicitly is redundant. When $tag resolves to <a>, role='button' would // override the correct implicit role='link' semantic, breaking accessibility. // Source: w3.org/TR/wai-aria-1.2/#button / w3.org/TR/html-aam-1.0/#a-element ] ); if ( ! empty( $settings['size'] ) ) { $this->add_render_attribute( 'button', 'class', 'elementor-size-' . $settings['size'] ); } if ( ! empty( $settings['button_id'] ) ) { $this->add_render_attribute( 'button', 'id', sanitize_key( $settings['button_id'] ) ); } // ⛔ hover_animation excluded by default — uncomment if control is re-enabled: // if ( ! empty( $settings['hover_animation'] ) ) { // $this->add_render_attribute( 'button', 'class', 'elementor-animation-' . $settings['hover_animation'] ); // } if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'button', $settings['link'] ); $this->add_render_attribute( 'button', 'class', 'elementor-button-link' ); $tag = 'a'; } else { $tag = 'button'; } ?> <div <?php $this->print_render_attribute_string( 'wrapper' ); ?>> <<?php echo esc_attr( $tag ); ?> <?php $this->print_render_attribute_string( 'button' ); ?>> <span class="elementor-button-content-wrapper"> <?php if ( ! empty( $settings['selected_icon']['value'] ) && 'left' === $settings['icon_align'] ) : ?> <span class="elementor-button-icon elementor-align-icon-left"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> <?php endif; ?> <?php if ( ! empty( $settings['text'] ) ) : ?> <span class="elementor-button-text"> <?php echo esc_html( $settings['text'] ); ?> </span> <?php endif; ?> <?php if ( ! empty( $settings['selected_icon']['value'] ) && 'right' === $settings['icon_align'] ) : ?> <span class="elementor-button-icon elementor-align-icon-right"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> <?php endif; ?> </span> </<?php echo esc_attr( $tag ); ?>> </div> <?php } protected function content_template(): void { ?> <# var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' ); var hasIcon = iconHTML && iconHTML.rendered; var tag = settings.link && settings.link.url ? 'a' : 'button'; var sizeClass = settings.size ? 'elementor-size-' + settings.size : ''; // ⛔ hover_animation excluded — uncomment if control is re-enabled: // var animClass = settings.hover_animation ? 'elementor-animation-' + settings.hover_animation : ''; #> <div class="elementor-button-wrapper"> <{{{ tag }}} class="elementor-button {{{ sizeClass }}}"> <span class="elementor-button-content-wrapper"> <# if ( hasIcon && 'left' === settings.icon_align ) { #> <span class="elementor-button-icon elementor-align-icon-left">{{{ iconHTML.value }}}</span> <# } #> <# if ( settings.text ) { #> <span class="elementor-button-text">{{ settings.text }}</span> <# } #> <# if ( hasIcon && 'right' === settings.icon_align ) { #> <span class="elementor-button-icon elementor-align-icon-right">{{{ iconHTML.value }}}</span> <# } #> </span> </{{{ tag }}}> </div> <?php } ``` > **Remove checklist:** > - `button_type` → remove if no semantic type variants needed > - `size` → remove if button size is controlled purely via `text_padding` > - `selected_icon` + `icon_align` + `icon_indent` → remove if no icon support needed > - `button_id` → remove if button does not need JS/anchor targeting > - `hover_animation` → **excluded by default** (commented out) > - `button_hover_border_color` → remove if no border is used > - `text_shadow` → remove for simple flat-design buttons --- -
widget-container.md 19.2 KB
# Widget Boilerplate — Container > **When to use this file:** Load this boilerplate whenever building a widget that acts as a > layout wrapper — a section, card shell, hero block, or any element that wraps other content. > > Mirrors Elementor's native Container (Flexbox) panel exactly in control structure and order. > Use these controls inside `register_controls()` of your `Widget_Base` subclass. --- ### Container Widget Boilerplate Mirrors Elementor's native Container (Flexbox) panel. Use whenever a widget acts as a layout wrapper — a section, card shell, hero block, or any element that contains other widgets or content groups. > ⚠️ This boilerplate targets Elementor's Container element structure. When building a > custom widget that internally renders a container-like wrapper, use these controls inside > `register_controls()` of your `Widget_Base` subclass to match the UX pattern users already > know from native containers. ```php protected function register_controls(): void { // ========================================================= // TAB: LAYOUT // ========================================================= // ── LAYOUT ──────────────────────────────────────────────── // ✅ TAB_LAYOUT is NOT available in Widget_Base — it exists only on the native Container/Section // element. Custom widgets only support TAB_CONTENT, TAB_STYLE, TAB_ADVANCED. // Use TAB_CONTENT for layout controls in custom widgets. $this->start_controls_section( 'section_layout', [ 'label' => esc_html__( 'Container', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'container_layout', [ 'label' => esc_html__( 'Container Layout', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'flexbox', 'options' => [ 'flexbox' => esc_html__( 'Flexbox', 'myplugin' ), // 'grid' => esc_html__( 'Grid', 'myplugin' ), // Elementor Pro 3.16+ only ], ] ); $this->add_control( 'content_width', [ 'label' => esc_html__( 'Content Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'full', 'options' => [ 'full' => esc_html__( 'Full Width', 'myplugin' ), 'boxed' => esc_html__( 'Boxed', 'myplugin' ), ], 'selectors_dictionary' => [ 'full' => '100%', 'boxed' => 'var(--container-max-width, 1140px)', ], 'selectors' => [ '{{WRAPPER}} .myplugin-container__inner' => 'max-width: {{VALUE}};', ], ] ); $this->add_responsive_control( 'width', [ 'label' => esc_html__( 'Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 90, 'unit' => '%' ], 'size_units' => [ 'px', '%', 'vw' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 2000 ], '%' => [ 'min' => 0, 'max' => 100 ], 'vw' => [ 'min' => 0, 'max' => 100 ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'min_height', [ 'label' => esc_html__( 'Min Height', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 100, 'unit' => 'vh' ], 'size_units' => [ 'px', 'vh', 'vw', 'em', 'rem' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 1500 ], 'vh' => [ 'min' => 0, 'max' => 100 ], 'vw' => [ 'min' => 0, 'max' => 100 ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'min-height: {{SIZE}}{{UNIT}};', ], 'description' => esc_html__( 'To achieve full height Container use 100vh.', 'myplugin' ), ] ); $this->add_control( 'items_heading', [ 'label' => esc_html__( 'Items', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, 'separator' => 'before', ] ); $this->add_responsive_control( 'flex_direction', [ 'label' => esc_html__( 'Direction', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'default' => 'row', 'options' => [ 'row' => [ 'title' => esc_html__( 'Row', 'myplugin' ), 'icon' => 'eicon-arrow-right' ], 'column' => [ 'title' => esc_html__( 'Column', 'myplugin' ), 'icon' => 'eicon-arrow-down' ], 'row-reverse' => [ 'title' => esc_html__( 'Row Reverse', 'myplugin' ), 'icon' => 'eicon-arrow-left' ], 'column-reverse' => [ 'title' => esc_html__( 'Column Reverse', 'myplugin' ), 'icon' => 'eicon-arrow-up' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'flex-direction: {{VALUE}};', ], ] ); $this->add_responsive_control( 'justify_content', [ 'label' => esc_html__( 'Justify Content', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'default' => 'flex-start', 'options' => [ 'flex-start' => [ 'title' => esc_html__( 'Start', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-start-h' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-center-h' ], 'flex-end' => [ 'title' => esc_html__( 'End', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-end-h' ], 'space-between' => [ 'title' => esc_html__( 'Space Between', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-space-between-h' ], 'space-around' => [ 'title' => esc_html__( 'Space Around', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-space-around-h' ], 'space-evenly' => [ 'title' => esc_html__( 'Space Evenly', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-space-evenly-h' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'justify-content: {{VALUE}};', ], ] ); $this->add_responsive_control( 'align_items', [ 'label' => esc_html__( 'Align Items', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'default' => 'flex-start', 'options' => [ 'flex-start' => [ 'title' => esc_html__( 'Start', 'myplugin' ), 'icon' => 'eicon-flex eicon-align-start-v' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-flex eicon-align-center-v' ], 'flex-end' => [ 'title' => esc_html__( 'End', 'myplugin' ), 'icon' => 'eicon-flex eicon-align-end-v' ], 'stretch' => [ 'title' => esc_html__( 'Stretch', 'myplugin' ), 'icon' => 'eicon-flex eicon-align-stretch-v' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'align-items: {{VALUE}};', ], ] ); // ✅ Two separate gap controls — universally safe across all Elementor versions. // Controls_Manager::GAPS exists since 3.7 but its ROW.SIZE/COLUMN.SIZE selector tokens // are not documented in official Elementor developer docs. Use separate sliders instead. $this->add_responsive_control( 'column_gap', [ 'label' => esc_html__( 'Column Gap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 20, 'unit' => 'px' ], 'size_units' => [ 'px', '%', 'em', 'rem', 'vw' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 200 ], '%' => [ 'min' => 0, 'max' => 100 ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'column-gap: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'row_gap', [ 'label' => esc_html__( 'Row Gap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 20, 'unit' => 'px' ], 'size_units' => [ 'px', '%', 'em', 'rem', 'vw' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 200 ], '%' => [ 'min' => 0, 'max' => 100 ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'row-gap: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'flex_wrap', [ 'label' => esc_html__( 'Wrap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'default' => 'nowrap', 'options' => [ 'nowrap' => [ 'title' => esc_html__( 'No Wrap', 'myplugin' ), 'icon' => 'eicon-flex eicon-nowrap' ], 'wrap' => [ 'title' => esc_html__( 'Wrap', 'myplugin' ), 'icon' => 'eicon-flex eicon-wrap' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'flex-wrap: {{VALUE}};', ], 'description' => esc_html__( 'Items within the container can stay in a single line (No wrap), or break into multiple lines (Wrap).', 'myplugin' ), ] ); $this->end_controls_section(); $this->start_controls_section( 'section_additional', [ 'label' => esc_html__( 'Additional Options', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); // Add widget-specific layout extras here (overflow, z-index, etc.) $this->end_controls_section(); // ========================================================= // TAB: STYLE // ========================================================= $this->start_controls_section( 'section_background', [ 'label' => esc_html__( 'Background', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->start_controls_tabs( 'tabs_background' ); $this->start_controls_tab( 'tab_background_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ), ] ); $this->add_group_control( \Elementor\Group_Control_Background::get_type(), [ 'name' => 'background', // ✅ 'classic' and 'gradient' only — per official Elementor docs: // "Video and slideshow types are supported only at section/container // level, not widget level." // Source: developers.elementor.com/docs/editor-controls/group-control-background/ 'types' => [ 'classic', 'gradient' ], 'selector' => '{{WRAPPER}} .myplugin-container', ] ); $this->end_controls_tab(); $this->start_controls_tab( 'tab_background_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ), ] ); $this->add_group_control( \Elementor\Group_Control_Background::get_type(), [ 'name' => 'background_hover', 'types' => [ 'classic', 'gradient' ], 'selector' => '{{WRAPPER}} .myplugin-container:hover', ] ); $this->end_controls_tab(); $this->end_controls_tabs(); // ✅ Scrolling Effects & Mouse Effects — Elementor Pro only. // These controls register fine on free Elementor but have no effect without Pro. // Remove entirely if plugin targets free Elementor only. $this->add_control( 'scrolling_effects', [ 'label' => esc_html__( 'Scrolling Effects', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', 'separator' => 'before', // ⚠️ PRO ONLY 'description' => esc_html__( 'Requires Elementor Pro.', 'myplugin' ), ] ); $this->add_control( 'mouse_effects', [ 'label' => esc_html__( 'Mouse Effects', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', // ⚠️ PRO ONLY 'description' => esc_html__( 'Requires Elementor Pro.', 'myplugin' ), ] ); $this->end_controls_section(); $this->start_controls_section( 'section_background_overlay', [ 'label' => esc_html__( 'Background Overlay', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->start_controls_tabs( 'tabs_background_overlay' ); $this->start_controls_tab( 'tab_background_overlay_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ), ] ); $this->add_group_control( \Elementor\Group_Control_Background::get_type(), [ 'name' => 'background_overlay', 'types' => [ 'classic', 'gradient' ], 'selector' => '{{WRAPPER}} .myplugin-container__overlay', ] ); $this->end_controls_tab(); $this->start_controls_tab( 'tab_background_overlay_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ), ] ); $this->add_group_control( \Elementor\Group_Control_Background::get_type(), [ 'name' => 'background_overlay_hover', 'types' => [ 'classic', 'gradient' ], 'selector' => '{{WRAPPER}} .myplugin-container:hover .myplugin-container__overlay', ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->end_controls_section(); $this->start_controls_section( 'section_border', [ 'label' => esc_html__( 'Border', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->start_controls_tabs( 'tabs_border' ); $this->start_controls_tab( 'tab_border_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ), ] ); $this->add_group_control( \Elementor\Group_Control_Border::get_type(), [ 'name' => 'border', 'selector' => '{{WRAPPER}} .myplugin-container', ] ); // ✅ Default 16px all sides matches Elementor's native container default $this->add_responsive_control( 'border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'default' => [ 'top' => '16', 'right' => '16', 'bottom' => '16', 'left' => '16', 'unit' => 'px', 'isLinked' => true, ], 'selectors' => [ '{{WRAPPER}} .myplugin-container' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Box_Shadow::get_type(), [ 'name' => 'box_shadow', 'selector' => '{{WRAPPER}} .myplugin-container', ] ); $this->end_controls_tab(); $this->start_controls_tab( 'tab_border_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ), ] ); $this->add_group_control( \Elementor\Group_Control_Border::get_type(), [ 'name' => 'border_hover', 'selector' => '{{WRAPPER}} .myplugin-container:hover', ] ); $this->add_responsive_control( 'border_radius_hover', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-container:hover' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Box_Shadow::get_type(), [ 'name' => 'box_shadow_hover', 'selector' => '{{WRAPPER}} .myplugin-container:hover', ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->end_controls_section(); // Shape Divider has no native Controls_Manager API. Implement via CSS or SVG in render(). $this->start_controls_section( 'section_shape_divider', [ 'label' => esc_html__( 'Shape Divider', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'shape_divider_notice', [ 'type' => \Elementor\Controls_Manager::RAW_HTML, 'raw' => esc_html__( 'Shape dividers are implemented via CSS or inline SVG in render(). Add your shape controls here if needed.', 'myplugin' ), 'content_classes' => 'elementor-descriptor', ] ); $this->end_controls_section(); } ``` **Matching render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $this->add_render_attribute( 'container', 'class', 'myplugin-container' ); ?> <div <?php $this->print_render_attribute_string( 'container' ); ?>> <div class="myplugin-container__overlay"></div> <div class="myplugin-container__inner"> <?php // Render child content here ?> </div> </div> <?php } ``` > **Remove checklist:** > - `container_layout` → remove if widget only ever uses Flexbox > - `content_width` + inner wrapper div → remove if container is always full width > - `min_height` → remove for widgets that size naturally to their content > - `flex_wrap` → remove if items must always stay single line > - `scrolling_effects` + `mouse_effects` → remove if Elementor Pro is not required > - `section_background_overlay` + overlay div → remove if no overlay needed > - `section_shape_divider` → remove if no shape divider needed > - Border Radius default 16px → adjust or clear for sharp-corner designs --- **content_template() skeleton:** ```php protected function content_template(): void { ?> <div class="myplugin-container"> <div class="myplugin-container__overlay"></div> <div class="myplugin-container__inner"></div> </div> <?php } ``` -
widget-counter.md 9.8 KB
# Widget Boilerplate — Counter > **When to use this file:** Load whenever building a widget that animates a number counting up. > Verified against `elementor/includes/widgets/counter.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_counter', [ 'label' => esc_html__( 'Counter', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'starting_number', [ 'label' => esc_html__( 'Starting Number', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'default' => 0, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'ending_number', [ 'label' => esc_html__( 'Ending Number', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'default' => 100, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'prefix', [ 'label' => esc_html__( 'Number Prefix', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => '', 'placeholder' => esc_html__( '$', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'suffix', [ 'label' => esc_html__( 'Number Suffix', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => '', 'placeholder' => esc_html__( 'Plus', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'duration', [ 'label' => esc_html__( 'Animation Duration', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'default' => 2000, 'min' => 100, 'step' => 100, ] ); $this->add_control( 'thousand_separator', [ 'label' => esc_html__( 'Thousand Separator', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ] ); $this->add_control( 'thousand_separator_char', [ 'label' => esc_html__( 'Separator', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'condition' => [ 'thousand_separator' => 'yes' ], 'options' => [ '' => esc_html__( 'Default', 'myplugin' ), '.' => esc_html__( 'Dot', 'myplugin' ), ' ' => esc_html__( 'Space', 'myplugin' ), ], ] ); $this->add_control( 'title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'label_block' => true, 'default' => esc_html__( 'Cool Number', 'myplugin' ), 'placeholder' => esc_html__( 'Enter your counter title', 'myplugin' ), 'separator' => 'before', 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_number', [ 'label' => esc_html__( 'Number', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'number_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-counter-number-wrapper' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], 'selector' => '{{WRAPPER}} .myplugin-counter-number-wrapper', ] ); $this->end_controls_section(); $this->start_controls_section( 'section_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_SECONDARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-counter-title' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_SECONDARY ], 'selector' => '{{WRAPPER}} .myplugin-counter-title', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $this->add_render_attribute( 'counter', [ 'class' => 'myplugin-counter-number', 'data-duration' => $settings['duration'], 'data-to-value' => $settings['ending_number'], 'data-from-value' => $settings['starting_number'], 'data-delimiter' => $settings['thousand_separator'] ? ( $settings['thousand_separator_char'] ?: ',' ) : '', ] ); ?> <div class="myplugin-counter"> <div class="myplugin-counter-number-wrapper"> <span class="myplugin-counter-number-prefix"><?php echo esc_html( $settings['prefix'] ); ?></span> <span <?php $this->print_render_attribute_string( 'counter' ); ?>><?php echo esc_html( $settings['starting_number'] ); ?></span> <span class="myplugin-counter-number-suffix"><?php echo esc_html( $settings['suffix'] ); ?></span> </div> <?php if ( ! empty( $settings['title'] ) ) : ?> <p class="myplugin-counter-title"><?php echo esc_html( $settings['title'] ); ?></p> <?php endif; ?> </div> <?php } ``` > ⚠️ **Required: Counter JS animation.** The `data-duration`, `data-to-value`, and > `data-from-value` attributes output by `render()` do nothing without a JS driver. > Use an `IntersectionObserver` to start the animation when the widget scrolls into view: > ```js > ( () => { > 'use strict'; > const animateCounter = ( el ) => { > const from = +el.dataset.fromValue || 0; > const to = +el.dataset.toValue || 0; > const duration = +el.dataset.duration || 2000; > const delim = el.dataset.delimiter || ''; > const start = performance.now(); > const tick = ( now ) => { > const elapsed = Math.min( now - start, duration ); > const progress = elapsed / duration; > const current = Math.round( from + ( to - from ) * progress ); > el.textContent = delim > ? current.toLocaleString() > : String( current ); > if ( elapsed < duration ) requestAnimationFrame( tick ); > }; > // ✅ Respect prefers-reduced-motion — skip animation, jump to final value > if ( window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) { > el.textContent = String( to ); > return; > } > requestAnimationFrame( tick ); > }; > > window.addEventListener( 'elementor/frontend/init', () => { > window.elementorFrontend.hooks.addAction( > 'frontend/element_ready/myplugin-widget.default', > ( $scope ) => { > if ( ! $scope || ! $scope[0] ) return; > const counter = $scope[0].querySelector( '.myplugin-counter-number' ); > if ( ! counter ) return; > const observer = new IntersectionObserver( ( entries ) => { > entries.forEach( entry => { > if ( entry.isIntersecting ) { > animateCounter( counter ); > observer.unobserve( entry.target ); > } > } ); > }, { threshold: 0.5 } ); > observer.observe( counter ); > } > ); > } ); > } )(); > ``` > **Remove checklist:** > - `prefix` + `suffix` → remove if counter shows a plain number only > - `thousand_separator` → remove if numbers are always small > - `title` → remove if counter has no label **content_template() skeleton:** ```php protected function content_template(): void { ?> <div class="myplugin-counter"> <div class="myplugin-counter-number-wrapper"> <span class="myplugin-counter-number-prefix">{{ settings.prefix }}</span> <span class="myplugin-counter-number" data-duration="{{ settings.duration }}" data-to-value="{{ settings.ending_number }}" data-from-value="{{ settings.starting_number }}">{{ settings.starting_number }}</span> <span class="myplugin-counter-number-suffix">{{ settings.suffix }}</span> </div> <# if ( settings.title ) { #> <p class="myplugin-counter-title">{{ settings.title }}</p> <# } #> </div> <?php } ``` -
widget-divider.md 9.3 KB
# Widget Boilerplate — Divider > **When to use this file:** Load whenever building a widget that separates content sections with a styled line. > Verified against `elementor/includes/widgets/divider.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_divider', [ 'label' => esc_html__( 'Divider', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'style', [ 'label' => esc_html__( 'Style', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'solid', 'options' => [ 'solid' => esc_html__( 'Solid', 'myplugin' ), 'double' => esc_html__( 'Double', 'myplugin' ), 'dotted' => esc_html__( 'Dotted', 'myplugin' ), 'dashed' => esc_html__( 'Dashed', 'myplugin' ), ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider-separator' => 'border-top-style: {{VALUE}};', ], 'prefix_class' => 'myplugin-divider-style-', ] ); $this->add_responsive_control( 'width', [ 'label' => esc_html__( 'Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'size_units' => [ '%', 'px', 'vw' ], 'range' => [ '%' => [ 'min' => 1, 'max' => 100 ], 'px' => [ 'min' => 1, 'max' => 1000 ], 'vw' => [ 'min' => 1, 'max' => 100 ], ], 'default' => [ 'unit' => '%', 'size' => 100 ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider-separator' => 'width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider' => 'text-align: {{VALUE}};', // ✅ margin trick centres the separator line regardless of width '{{WRAPPER}} .myplugin-divider-separator' => 'margin: 0 auto; margin-{{VALUE}}: 0;', ], ] ); // Add Element — none / text / icon $this->add_control( 'look', [ 'label' => esc_html__( 'Add Element', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'line' => [ 'title' => esc_html__( 'None', 'myplugin' ), 'icon' => 'eicon-minus' ], 'line_text' => [ 'title' => esc_html__( 'Text', 'myplugin' ), 'icon' => 'eicon-t-letter' ], 'line_icon' => [ 'title' => esc_html__( 'Icon', 'myplugin' ), 'icon' => 'eicon-star' ], ], 'default' => 'line', 'prefix_class'=> 'myplugin-divider-', 'label_block' => false, ] ); $this->add_control( 'text', [ 'label' => esc_html__( 'Text', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Divider', 'myplugin' ), 'condition' => [ 'look' => 'line_text' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'default' => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ], 'condition' => [ 'look' => 'line_icon' ], ] ); // Text/icon position relative to the line $this->add_control( 'text_align', [ 'label' => esc_html__( 'Position', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-h-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-h-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-h-align-right' ], ], 'default' => 'center', 'condition' => [ 'look!' => 'line' ], ] ); $this->add_control( 'middle_width', [ 'label' => esc_html__( 'Spacing', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 1, 'max' => 50 ] ], 'default' => [ 'size' => 10, 'unit' => 'px' ], 'condition' => [ 'look!' => 'line' ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider__text' => 'padding: 0 {{SIZE}}{{UNIT}};', '{{WRAPPER}} .myplugin-divider__icon' => 'padding: 0 {{SIZE}}{{UNIT}};', ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_divider_style', [ 'label' => esc_html__( 'Divider', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_SECONDARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider-separator' => 'border-top-color: {{VALUE}};', '{{WRAPPER}} .myplugin-divider__text' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-divider__icon i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-divider__icon svg' => 'fill: {{VALUE}};', ], ] ); $this->add_control( 'weight', [ 'label' => esc_html__( 'Weight', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 1, 'max' => 10 ] ], 'default' => [ 'size' => 1, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider-separator' => 'border-top-width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'gap', [ 'label' => esc_html__( 'Gap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'size_units' => [ 'px', 'em', 'rem' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'default' => [ 'size' => 15, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-divider' => 'padding-top: {{SIZE}}{{UNIT}}; padding-bottom: {{SIZE}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); ?> <div class="myplugin-divider"> <span class="myplugin-divider-separator"> <?php if ( 'line_text' === $settings['look'] && ! empty( $settings['text'] ) ) : ?> <span class="myplugin-divider__text"><?php echo esc_html( $settings['text'] ); ?></span> <?php elseif ( 'line_icon' === $settings['look'] && ! empty( $settings['selected_icon']['value'] ) ) : ?> <span class="myplugin-divider__icon"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> <?php endif; ?> </span> </div> <?php } ``` > **Remove checklist:** > - `look` + `text` + `selected_icon` + `text_align` + `middle_width` → remove if divider is always a plain line **content_template() skeleton:** ```php protected function content_template(): void { ?> <div class="myplugin-divider"> <span class="myplugin-divider-separator"> <# if ( 'line_text' === settings.look && settings.text ) { #> <span class="myplugin-divider__text">{{ settings.text }}</span> <# } else if ( 'line_icon' === settings.look && settings.selected_icon && settings.selected_icon.value ) { var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' ); #> <span class="myplugin-divider__icon">{{{ iconHTML.value }}}</span> <# } #> </span> </div> <?php } ``` -
widget-elementor-template.md 11.2 KB
# Widget Pattern — Render an Elementor Template > **When to use this file:** Load whenever building a widget that renders a saved > Elementor template (page, section, or reusable block) inside its `render()`. > This is the **default / recommended approach** when a widget needs a user-designed > inner layout. Use `widget-php-template.md` only when the markup is static PHP. > > **API source:** `\Elementor\Plugin::$instance->frontend->get_builder_content_for_display()` > Source: `elementor/includes/frontend.php` — Elementor GitHub main branch. > Note: `get_builder_content()` is the internal method; always call the public > `get_builder_content_for_display()` wrapper instead. --- ## How It Works The user picks a saved Elementor template (post type `elementor_library`) by ID via a SELECT control in your widget. Your `render()` calls `get_builder_content_for_display( $template_id, $with_css )` which: 1. Verifies the post exists and is built with Elementor. 2. Renders all Elementor elements in that template through the normal frontend pipeline. 3. Returns the full rendered HTML string — echo it directly. > ⚠️ **`$with_css = true` vs `false`:** > Both values always produce CSS — the difference is WHERE it is output. > Source: `elementor/includes/frontend.php` (Elementor GitHub main branch). > - `false` (default) — CSS is enqueued to the page `<head>` via `wp_enqueue_style`. > Correct for standard frontend use on any Elementor-built page. > - `true` — CSS is printed inline as a `<style>` block directly above the HTML in the > returned string. Use for AJAX responses, shortcodes loaded after `wp_head`, or any > context where `<head>` is unavailable or has already fired. > Note: Elementor automatically forces `$with_css = true` on AJAX and Customizer requests. --- ## register_controls() skeleton ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────────────────────── $this->start_controls_section( 'section_template', [ 'label' => esc_html__( 'Template', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); // ✅ SELECT2 control — searchable dropdown for large lists of templates. // Using plain SELECT is fine for small lists but SELECT2 is the correct // control for elementor_library which can grow to hundreds of entries. // Cast each option key to string — WP_Post->ID is int; SELECT2 compares as strings. $this->add_control( 'template_id', [ 'label' => esc_html__( 'Choose Template', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT2, 'options' => $this->get_template_options(), 'default' => '', 'label_block' => true, // ✅ is_dynamic_content() must return true when template_id is used, // because the rendered output varies per template and must not be cached. // See is_dynamic_content() override in the render skeleton below. ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────────────────────── $this->start_controls_section( 'section_wrapper_style', [ 'label' => esc_html__( 'Wrapper', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'min_height', [ 'label' => esc_html__( 'Min Height', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'size_units' => [ 'px', 'vh', 'em', 'rem' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 1000 ] ], 'selectors' => [ '{{WRAPPER}} .myplugin-template-wrapper' => 'min-height: {{SIZE}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` --- ## Required helper method ```php /** * Build SELECT options from published Elementor templates. * * Queries the elementor_library CPT directly — avoids loading the full * Template Library API which is Pro-only for certain source types. * * @return array<string, string> Option key = post ID (string), value = post title. */ private function get_template_options(): array { // ✅ Empty string key '' matches the SELECT2 default value of ''. // Using '0' as the placeholder key would be treated as a valid post ID. $options = [ '' => esc_html__( '— Select Template —', 'myplugin' ) ]; $templates = get_posts( [ 'post_type' => 'elementor_library', 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', // ✅ no_found_rows: true — skip COUNT(*) since we don't paginate here 'no_found_rows' => true, ] ); foreach ( $templates as $template ) { $options[ (string) $template->ID ] = $template->post_title; } return $options; } ``` --- ## has_widget_inner_wrapper() + is_dynamic_content() + render() skeleton ```php public function has_widget_inner_wrapper(): bool { return false; } // ✅ MUST return true — rendered output depends entirely on which template // the user selects. Caching the output would freeze one template's HTML // permanently, even after the user changes the selection. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return true; } protected function render(): void { $settings = $this->get_settings_for_display(); $template_id = (int) ( $settings['template_id'] ?? 0 ); if ( $template_id < 1 ) { // ✅ Show a placeholder in the editor so the user knows to pick a template if ( \Elementor\Plugin::$instance->editor && \Elementor\Plugin::$instance->editor->is_edit_mode() ) { echo '<div style="padding:2rem;text-align:center;background:#f0f0f0;color:#888;">' . esc_html__( 'Please select an Elementor template.', 'myplugin' ) . '</div>'; } return; } // ✅ Guard: do NOT render if template_id equals the current post ID. // Elementor itself outputs this exact error string when recursion is detected. // Source: elementor/includes/frontend.php — get_builder_content_for_display() if ( $template_id === get_the_ID() ) { echo '<div class="elementor-alert elementor-alert-danger">' . esc_html__( 'Invalid Data: The Template ID cannot be the same as the currently edited template. Please choose a different one.', 'elementor' ) . '</div>'; return; } // ✅ Verify the post exists and is published before passing to Elementor. // get_builder_content_for_display() silently returns '' for missing posts. $template_post = get_post( $template_id ); if ( ! $template_post || 'publish' !== $template_post->post_status ) { if ( \Elementor\Plugin::$instance->editor && \Elementor\Plugin::$instance->editor->is_edit_mode() ) { echo '<div style="padding:1rem;background:#fff3cd;color:#856404;">' . esc_html__( 'Selected template is not published or does not exist.', 'myplugin' ) . '</div>'; } return; } echo '<div class="myplugin-template-wrapper">'; // ✅ get_builder_content_for_display() is the public API. // $with_css = true — always inline the template CSS to ensure styles load // even when this widget is the only Elementor element on the page. // Source: elementor/includes/frontend.php (Elementor GitHub main branch) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $template_id, true // $with_css ); echo '</div>'; } ``` --- ## content_template() skeleton ```php protected function content_template(): void { ?> <# var templateId = parseInt( settings.template_id, 10 ); #> <# if ( ! templateId ) { #> <div style="padding:2rem;text-align:center;background:#f0f0f0;color:#888;"> <?php echo esc_html__( 'Please select an Elementor template.', 'myplugin' ); ?> </div> <# } else { #> <div class="myplugin-template-wrapper myplugin-template-placeholder" style="padding:2rem;text-align:center;background:#e8f4fd;color:#0c5460;"> <span class="eicon-elementor" style="font-size:2rem;display:block;margin-bottom:.5rem;"></span> <?php echo esc_html__( 'Elementor Template ID:', 'myplugin' ); ?> <strong>{{ templateId }}</strong> <br> <small><?php echo esc_html__( 'Renders on frontend only.', 'myplugin' ); ?></small> </div> <# } #> <?php } ``` > **Why the placeholder?** `get_builder_content_for_display()` is a PHP method — > it cannot run inside the JS `content_template()`. The editor preview always shows > a placeholder. The actual rendered template is only visible on the frontend. --- ## Known limitations & gotchas > **Dynamic content in nested templates:** > If the selected template contains Dynamic Tags (Post Title, ACF fields, etc.), > `get_builder_content_for_display()` renders them in the context of the current > page's `$post` — it does **not** switch the global `$post` to the template post. > This is expected behaviour. > Source: github.com/elementor/elementor/issues/31600 · github.com/elementor/elementor/issues/13751 If you need the template's Dynamic Tags to read a **specific** post (e.g. a CPT entry whose ID differs from the current page), set up that post context explicitly before the call: ```php // Make Dynamic Tags inside the template resolve against $target_id, not the current page. global $post; $original = $post; $post = get_post( $target_id ); // the post whose meta the template should read setup_postdata( $post ); echo \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $template_id, true ); wp_reset_postdata(); $post = $original; // belt-and-braces restore ``` > **Recursive template guard:** > Never allow a template to embed itself. The `$template_id === get_the_ID()` guard > above mirrors Elementor's own internal check in `frontend.php`. Without it, > recursive rendering will produce a fatal error or infinite loop. > **Output caching:** > Because `is_dynamic_content()` returns `true`, Elementor will never cache this > widget's output. This is correct — the rendered HTML changes depending on the > template the user selects and its own dynamic content. > **Page CSS vs inline CSS:** > `$with_css = true` adds a `<style>` block inline. On high-traffic pages, consider > whether this causes duplicate style output if many instances exist. An alternative > is to enqueue the template's CSS file separately using > `\Elementor\Core\Files\CSS\Post` — but `$with_css = true` is the simplest safe default. --- > **Remove checklist:** > - `min_height` responsive control → remove for simple full-bleed embeds > - Editor placeholder styles → adjust colours to match project design system > - `get_template_options()` query → add `'meta_query'` to filter by template type if needed -
widget-google-maps.md 5.3 KB
# Widget Boilerplate — Google Maps > **When to use this file:** Load whenever building a widget that embeds a Google Maps iframe. > Verified against `elementor/includes/widgets/google-maps.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_map', [ 'label' => esc_html__( 'Map', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'address', [ 'label' => esc_html__( 'Location', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'placeholder' => esc_html__( 'London Eye, London, UK', 'myplugin' ), 'default' => esc_html__( 'London Eye, London, UK', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'zoom', [ 'label' => esc_html__( 'Zoom', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 10 ], 'range' => [ 'px' => [ 'min' => 1, 'max' => 20 ] ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_map_style', [ 'label' => esc_html__( 'Map', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'height', [ 'label' => esc_html__( 'Height', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 40, 'max' => 1440 ] ], 'default' => [ 'size' => 300, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-map' => 'height: {{SIZE}}{{UNIT}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Css_Filter::get_type(), [ 'name' => 'map_filter', 'selector' => '{{WRAPPER}} .myplugin-map iframe' ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['address'] ) ) return; $zoom = (int) ( $settings['zoom']['size'] ?? 10 ); $address = rawurlencode( $settings['address'] ); // ✅ TWO URL PATTERNS — choose based on whether a Google API key is configured. // // PATTERN A (no API key) — legacy Maps embed, no key required, still functional: // https://maps.google.com/maps?q={address}&t=m&z={zoom}&output=embed&iwloc=near // This is the same fallback Elementor's native widget uses when no API key is set. // It is NOT deprecated for no-key usage — Google still serves it and Elementor relies // on it. Source: elementor/includes/widgets/google-maps.php (Elementor 3.35+ / V3 Widget_Base API, current through 4.2) // // PATTERN B (with API key) — official Maps Embed API, recommended by Google: // https://www.google.com/maps/embed/v1/place?key={API_KEY}&q={address}&zoom={zoom} // Requires a Google Cloud project with Maps Embed API enabled (free, unlimited calls). // Source: developers.google.com/maps/documentation/embed/get-started // // Use a plugin option or site setting to switch between patterns: $api_key = get_option( 'myplugin_google_maps_api_key', '' ); if ( ! empty( $api_key ) ) { // Pattern B — Maps Embed API (preferred when key is available) $src = sprintf( 'https://www.google.com/maps/embed/v1/place?key=%s&q=%s&zoom=%d', rawurlencode( sanitize_text_field( $api_key ) ), $address, $zoom ); } else { // Pattern A — no-key fallback (still supported by Google, used by Elementor native) $src = sprintf( 'https://maps.google.com/maps?q=%s&t=m&z=%d&output=embed&iwloc=near', $address, $zoom ); } ?> <div class="myplugin-map"> <iframe loading="lazy" src="<?php echo esc_url( $src ); ?>" title="<?php echo esc_attr( $settings['address'] ); ?>" aria-label="<?php echo esc_attr( $settings['address'] ); ?>"></iframe> </div> <?php } ``` **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( settings.address ) { #> <div class="myplugin-map"> <div class="myplugin-map-placeholder" style="display:flex;align-items:center;justify-content:center;background:#eee;height:300px;"> <i class="eicon-map-pin" aria-hidden="true" style="font-size:48px;color:#aaa;"></i> </div> </div> <# } #> <?php } ``` -
widget-heading.md 8.5 KB
# Widget Boilerplate — Heading > **When to use this file:** Load whenever building a widget with a primary headline/title element. > Verified against `elementor/includes/widgets/heading.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXTAREA, 'dynamic' => [ 'active' => true ], 'placeholder' => esc_html__( 'Enter your title', 'myplugin' ), 'default' => esc_html__( 'Add Your Heading Text Here', 'myplugin' ), ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], 'separator' => 'before', ] ); $this->add_control( 'size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'default', 'options' => [ 'default' => esc_html__( 'Default', 'myplugin' ), 'small' => esc_html__( 'Small', 'myplugin' ), 'medium' => esc_html__( 'Medium', 'myplugin' ), 'large' => esc_html__( 'Large', 'myplugin' ), 'xl' => esc_html__( 'XL', 'myplugin' ), 'xxl' => esc_html__( 'XXL', 'myplugin' ), ], ] ); // ✅ header_size = the HTML tag — h1–h6, div, span, p $this->add_control( 'header_size', [ 'label' => esc_html__( 'HTML Tag', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6', 'div' => 'div', 'span' => 'span', 'p' => 'p', ], 'default' => 'h2', ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_title_style', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], 'justify' => [ 'title' => esc_html__( 'Justify', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-heading' => 'text-align: {{VALUE}};' ], ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-heading, {{WRAPPER}} .myplugin-heading a' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], 'selector' => '{{WRAPPER}} .myplugin-heading', ] ); $this->add_group_control( \Elementor\Group_Control_Text_Shadow::get_type(), [ 'name' => 'text_shadow', 'selector' => '{{WRAPPER}} .myplugin-heading', ] ); $this->add_control( 'blend_mode', [ 'label' => esc_html__( 'Blend Mode', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ '' => esc_html__( 'Normal', 'myplugin' ), 'multiply' => esc_html__( 'Multiply', 'myplugin' ), 'screen' => esc_html__( 'Screen', 'myplugin' ), 'overlay' => esc_html__( 'Overlay', 'myplugin' ), 'darken' => esc_html__( 'Darken', 'myplugin' ), 'lighten' => esc_html__( 'Lighten', 'myplugin' ), 'color-dodge' => esc_html__( 'Color Dodge', 'myplugin' ), 'saturation' => esc_html__( 'Saturation', 'myplugin' ), 'color' => esc_html__( 'Color', 'myplugin' ), 'difference' => esc_html__( 'Difference', 'myplugin' ), 'exclusion' => esc_html__( 'Exclusion', 'myplugin' ), 'hue' => esc_html__( 'Hue', 'myplugin' ), 'luminosity' => esc_html__( 'Luminosity', 'myplugin' ), ], 'selectors' => [ '{{WRAPPER}} .myplugin-heading' => 'mix-blend-mode: {{VALUE}};' ], 'separator' => 'none', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['title'] ) ) { return; } $this->add_render_attribute( 'title', 'class', 'myplugin-heading' ); if ( ! empty( $settings['size'] ) && 'default' !== $settings['size'] ) { $this->add_render_attribute( 'title', 'class', 'elementor-size-' . $settings['size'] ); } $title = $settings['title']; if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'url', $settings['link'] ); // ✅ wp_kses_post() on $title — TEXTAREA control returns raw user input. // get_settings_for_display() does not auto-escape TEXTAREA values. $title = sprintf( '<a %1$s>%2$s</a>', $this->get_render_attribute_string( 'url' ), wp_kses_post( $title ) ); } else { $title = wp_kses_post( $title ); } $tag = \Elementor\Utils::validate_html_tag( $settings['header_size'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- tag validated, title kses'd, attrs via add_render_attribute printf( '<%1$s %2$s>%3$s</%1$s>', $tag, $this->get_render_attribute_string( 'title' ), $title ); } protected function content_template(): void { ?> <# // ✅ Escape user text with _.escape() before it reaches {{{ title }}} — the triple-brace is // required for the optional <a> wrapper (HTML), so the text/URL must be escaped HERE, or it // is an editor-context XSS that wp.org review rejects. See field-notes.md §2. var title = _.escape( settings.title ); if ( settings.link && settings.link.url ) { title = '<a href="' + _.escape( settings.link.url ) + '">' + title + '</a>'; } var tag = elementor.helpers.validateHTMLTag( settings.header_size ); var size = settings.size && 'default' !== settings.size ? ' elementor-size-' + settings.size : ''; #> <{{{ tag }}} class="myplugin-heading{{{ size }}}">{{{ title }}}</{{{ tag }}}> <?php } ``` > **Remove checklist:** > - `size` → remove if heading size is controlled purely via typography > - `link` → remove if heading is never linked > - `blend_mode` → remove for simple designs > - `text_shadow` → remove for flat designs -
widget-html.md 2.4 KB
# Widget Boilerplate — HTML > **When to use this file:** Load whenever building a widget that outputs raw custom HTML/JS/CSS code. > Verified against `elementor/includes/widgets/html.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_html', [ 'label' => esc_html__( 'HTML', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'html', [ 'label' => esc_html__( 'HTML Code', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CODE, 'language' => 'html', 'rows' => 20, 'default' => '', 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['html'] ) ) { return; } // ✅ HTML widget outputs raw user HTML — this is intentional and matches // Elementor's native HTML widget behaviour. Only users with 'unfiltered_html' // capability can save arbitrary HTML via the CODE control in Elementor. // wp_kses_post() would strip scripts/iframes which defeats the widget's purpose. // The phpcs ignore is correct — document the intent clearly instead. echo $settings['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- intentional: HTML widget mirrors native Elementor HTML widget; unfiltered_html cap enforced by Elementor editor } ``` --- **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( settings.html ) { #> <div class="myplugin-html-wrapper">{{{ settings.html }}}</div> <# } #> <?php } ``` -
widget-icon-box.md 11.2 KB
# Widget Boilerplate — Icon Box > **When to use this file:** Load whenever building a widget combining an icon with a title and description. > Verified against `elementor/includes/widgets/icon-box.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_icon_box', [ 'label' => esc_html__( 'Icon Box', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'fa4compatibility' => 'icon', 'default' => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ], ] ); $this->add_control( 'title_text', [ 'label' => esc_html__( 'Title & Description', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'This is the heading', 'myplugin' ), 'placeholder' => esc_html__( 'Enter your title', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'description_text', [ 'label' => '', 'type' => \Elementor\Controls_Manager::TEXTAREA, 'default' => esc_html__( 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis pulvinar.', 'myplugin' ), 'placeholder' => esc_html__( 'Enter your description', 'myplugin' ), 'rows' => 10, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], 'separator' => 'before', ] ); $this->add_control( 'title_size', [ 'label' => esc_html__( 'Title HTML Tag', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6', 'div' => 'div', 'span' => 'span', 'p' => 'p', ], 'default' => 'h3', ] ); $this->end_controls_section(); // ── STYLE: Icon ─────────────────────────────────────────── $this->start_controls_section( 'section_style_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'position', [ 'label' => esc_html__( 'Position', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'default' => 'top', 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-h-align-left' ], 'top' => [ 'title' => esc_html__( 'Top', 'myplugin' ), 'icon' => 'eicon-v-align-top' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-h-align-right' ], ], 'prefix_class' => 'elementor-position%s-', ] ); $this->add_responsive_control( 'icon_size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 6, 'max' => 300 ] ], 'default' => [ 'size' => 50, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-icon' => 'font-size: {{SIZE}}{{UNIT}};', '{{WRAPPER}} .myplugin-icon-box-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};', ], ] ); $this->start_controls_tabs( 'icon_colors' ); $this->start_controls_tab( 'icon_colors_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ) ] ); $this->add_control( 'primary_color', [ 'label' => esc_html__( 'Primary Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-icon i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-icon-box-icon svg' => 'fill: {{VALUE}};', ], ] ); $this->end_controls_tab(); $this->start_controls_tab( 'icon_colors_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ) ] ); $this->add_control( 'hover_primary_color', [ 'label' => esc_html__( 'Primary Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}}:hover .myplugin-icon-box-icon i' => 'color: {{VALUE}};', '{{WRAPPER}}:hover .myplugin-icon-box-icon svg' => 'fill: {{VALUE}};', ], ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->end_controls_section(); // ── STYLE: Content ──────────────────────────────────────── $this->start_controls_section( 'section_style_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'text_align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-content' => 'text-align: {{VALUE}};' ], ] ); $this->add_control( 'heading_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, 'separator' => 'before', ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-title, {{WRAPPER}} .myplugin-icon-box-title a' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], 'selector' => '{{WRAPPER}} .myplugin-icon-box-title', ] ); $this->add_control( 'heading_description', [ 'label' => esc_html__( 'Description', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, 'separator' => 'before', ] ); $this->add_control( 'description_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-description' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'description_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], 'selector' => '{{WRAPPER}} .myplugin-icon-box-description', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $tag = \Elementor\Utils::validate_html_tag( $settings['title_size'] ); $title = $settings['title_text']; if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'link', $settings['link'] ); $title = '<a ' . $this->get_render_attribute_string( 'link' ) . '>' . esc_html( $title ) . '</a>'; } ?> <div class="myplugin-icon-box"> <div class="myplugin-icon-box-icon"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </div> <div class="myplugin-icon-box-content"> <<?php echo esc_attr( $tag ); ?> class="myplugin-icon-box-title"> <?php echo wp_kses_post( $title ); ?> </<?php echo esc_attr( $tag ); ?>> <?php if ( ! empty( $settings['description_text'] ) ) : ?> <p class="myplugin-icon-box-description"><?php echo wp_kses_post( $settings['description_text'] ); ?></p> <?php endif; ?> </div> </div> <?php } ``` > **Remove checklist:** > - `link` → remove if the box is never clickable > - `position` → remove if icon is always top-aligned > - Hover color tabs → remove if no hover state needed **content_template() skeleton:** ```php protected function content_template(): void { ?> <# var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' ); var tag = elementor.helpers.validateHTMLTag( settings.title_size ); // ✅ Escape user text/URL before it reaches {{{ title }}} (triple-brace needed for the <a> // wrapper) — otherwise it's an editor-context XSS wp.org rejects. See field-notes.md §2. var title = _.escape( settings.title_text ); if ( settings.link && settings.link.url ) { title = '<a href="' + _.escape( settings.link.url ) + '">' + title + '</a>'; } #> <div class="myplugin-icon-box"> <div class="myplugin-icon-box-icon">{{{ iconHTML.value }}}</div> <div class="myplugin-icon-box-content"> <{{{ tag }}} class="myplugin-icon-box-title">{{{ title }}}</{{{ tag }}}> <# if ( settings.description_text ) { #> <p class="myplugin-icon-box-description">{{ settings.description_text }}</p> <# } #> </div> </div> <?php } ``` -
widget-icon-list.md 10.7 KB
# Widget Boilerplate — Icon List > **When to use this file:** Load whenever building a bullet-list widget where each item has an icon, text, and optional link. > Verified against `elementor/includes/widgets/icon-list.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_icon_list', [ 'label' => esc_html__( 'Icon List', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'view', [ 'label' => esc_html__( 'Layout', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'default' => 'traditional', 'options' => [ 'traditional' => [ 'title' => esc_html__( 'Default', 'myplugin' ), 'icon' => 'eicon-editor-list-ul' ], 'inline' => [ 'title' => esc_html__( 'Inline', 'myplugin' ), 'icon' => 'eicon-ellipsis-h' ], ], 'prefix_class' => 'elementor-icon-list--layout-', ] ); $repeater = new \Elementor\Repeater(); $repeater->add_control( 'text', [ 'label' => esc_html__( 'Text', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'label_block' => true, 'default' => esc_html__( 'Text', 'myplugin' ), 'placeholder' => esc_html__( 'List Item', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $repeater->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'default' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ], 'fa4compatibility' => 'icon', ] ); $repeater->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'icon_list', [ 'label' => '', 'type' => \Elementor\Controls_Manager::REPEATER, 'fields' => $repeater->get_controls(), 'default' => [ [ 'text' => esc_html__( 'List Item #1', 'myplugin' ), 'selected_icon' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ] ], [ 'text' => esc_html__( 'List Item #2', 'myplugin' ), 'selected_icon' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ] ], [ 'text' => esc_html__( 'List Item #3', 'myplugin' ), 'selected_icon' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ] ], ], 'title_field' => '{{{ elementor.helpers.renderIcon( this, selected_icon, {}, "i", "panel" ) || \'<i class="\' + icon + \'"></i>\' }}} {{{ text }}}', ] ); $this->end_controls_section(); // ── STYLE: List ─────────────────────────────────────────── $this->start_controls_section( 'section_icon_list_style', [ 'label' => esc_html__( 'List', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'space_between', [ 'label' => esc_html__( 'Space Between', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 50 ] ], 'default' => [ 'size' => 5, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-list-item:not(:last-child)' => 'padding-bottom: {{SIZE}}{{UNIT}};', ], ] ); $this->add_control( 'divider', [ 'label' => esc_html__( 'Divider', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'separator' => 'before', ] ); $this->add_control( 'divider_style', [ 'label' => esc_html__( 'Style', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'solid', 'options' => [ 'solid' => esc_html__( 'Solid', 'myplugin' ), 'double' => esc_html__( 'Double', 'myplugin' ), 'dotted' => esc_html__( 'Dotted', 'myplugin' ), 'dashed' => esc_html__( 'Dashed', 'myplugin' ), ], 'condition' => [ 'divider' => 'yes' ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-list-item:not(:last-child)::after' => 'border-top-style: {{VALUE}};', ], ] ); $this->end_controls_section(); // ── STYLE: Icon ─────────────────────────────────────────── $this->start_controls_section( 'section_icon_style', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'icon_size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 14, 'unit' => 'px' ], 'range' => [ 'px' => [ 'min' => 6, 'max' => 300 ] ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon-list-icon i' => 'font-size: {{SIZE}}{{UNIT}};', '{{WRAPPER}} .myplugin-icon-list-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};', ], ] ); $this->add_control( 'icon_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} .myplugin-icon-list-icon i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-icon-list-icon svg' => 'fill: {{VALUE}};', ], ] ); $this->end_controls_section(); // ── STYLE: Text ─────────────────────────────────────────── $this->start_controls_section( 'section_text_style', [ 'label' => esc_html__( 'Text', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'text_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} .myplugin-icon-list-text' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'icon_typography', 'selector' => '{{WRAPPER}} .myplugin-icon-list-item', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); ?> <ul class="myplugin-icon-list-items"> <?php foreach ( $settings['icon_list'] as $index => $item ) : $repeater_key = $this->get_repeater_setting_key( 'text', 'icon_list', $index ); $this->add_render_attribute( $repeater_key, 'class', 'myplugin-icon-list-text' ); // ℹ️ For live in-panel text editing, you MAY also call // $this->add_inline_editing_attributes( $repeater_key, 'none' ) here. It is a current, // supported Elementor API (see SKILL.md §5) — not deprecated. Omitted by default to // keep the repeater output minimal. $tag = ! empty( $item['link']['url'] ) ? 'a' : 'span'; if ( 'a' === $tag ) { $link_key = $this->get_repeater_setting_key( 'link', 'icon_list', $index ); $this->add_link_attributes( $link_key, $item['link'] ); } ?> <li class="myplugin-icon-list-item elementor-repeater-item-<?php echo esc_attr( $item['_id'] ); ?>"> <<?php echo esc_attr( $tag ); ?> <?php echo 'a' === $tag ? $this->get_render_attribute_string( $link_key ) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_render_attribute_string() returns pre-sanitized attribute markup ?>> <?php if ( ! empty( $item['selected_icon']['value'] ) ) : ?> <span class="myplugin-icon-list-icon"> <?php \Elementor\Icons_Manager::render_icon( $item['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> <?php endif; ?> <span <?php $this->print_render_attribute_string( $repeater_key ); ?>><?php echo esc_html( $item['text'] ); ?></span> </<?php echo esc_attr( $tag ); ?>> </li> <?php endforeach; ?> </ul> <?php } ``` > **Remove checklist:** > - `divider` + `divider_style` → remove if list items never need separators > - `link` in repeater → remove if list items are never linked **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( ! settings.icon_list || ! settings.icon_list.length ) { return; } #> <ul class="myplugin-icon-list-items"> <# _.each( settings.icon_list, function( item ) { var iconHTML = elementor.helpers.renderIcon( view, item.selected_icon, { 'aria-hidden': true }, 'i', 'object' ); var tag = item.link && item.link.url ? 'a' : 'span'; // ✅ Escape the URL — it lands in an attribute via {{{ linkAttr }}} (see field-notes.md §2). var linkAttr = 'a' === tag ? 'href="' + _.escape( item.link.url ) + '"' : ''; #> <li class="myplugin-icon-list-item elementor-repeater-item-{{ item._id }}"> <{{{ tag }}} {{{ linkAttr }}}> <# if ( iconHTML && iconHTML.value ) { #> <span class="myplugin-icon-list-icon">{{{ iconHTML.value }}}</span> <# } #> <span class="myplugin-icon-list-text">{{ item.text }}</span> </{{{ tag }}}> </li> <# } ); #> </ul> <?php } ``` -
widget-icon.md 7.9 KB
# Widget Boilerplate — Icon > **When to use this file:** Load whenever building a widget that displays a single standalone icon. > Verified against `elementor/includes/widgets/icon.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'fa4compatibility' => 'icon', 'default' => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ], ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], 'separator' => 'before', ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_style_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ], ] ); $this->start_controls_tabs( 'icon_colors' ); $this->start_controls_tab( 'icon_colors_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ), ] ); $this->add_control( 'primary_color', [ 'label' => esc_html__( 'Primary Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} .myplugin-icon i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-icon svg' => 'fill: {{VALUE}};', ], 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], ] ); $this->end_controls_tab(); $this->start_controls_tab( 'icon_colors_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ), ] ); $this->add_control( 'hover_primary_color', [ 'label' => esc_html__( 'Primary Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} .myplugin-icon:hover i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-icon:hover svg' => 'fill: {{VALUE}};', ], ] ); // ⛔ hover_animation excluded by default — loads animation CSS library // $this->add_control( 'hover_animation', [ 'type' => \Elementor\Controls_Manager::HOVER_ANIMATION ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->add_responsive_control( 'size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 6, 'max' => 300 ] ], 'default' => [ 'size' => 50, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon' => 'font-size: {{SIZE}}{{UNIT}};', '{{WRAPPER}} .myplugin-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};', ], 'separator' => 'before', ] ); $this->add_responsive_control( 'icon_padding', [ 'label' => esc_html__( 'Padding', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'em' => [ 'min' => 0, 'max' => 5 ] ], 'default' => [ 'unit' => 'em' ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon' => 'padding: {{SIZE}}{{UNIT}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Border::get_type(), [ 'name' => 'border', 'selector' => '{{WRAPPER}} .myplugin-icon', 'separator' => 'before', ] ); $this->add_responsive_control( 'border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-icon' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Box_Shadow::get_type(), [ 'name' => 'box_shadow', 'selector' => '{{WRAPPER}} .myplugin-icon', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['selected_icon']['value'] ) ) { return; } $icon_tag = 'div'; if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'link', $settings['link'] ); $icon_tag = 'a'; } ?> <<?php echo esc_attr( $icon_tag ); ?> class="myplugin-icon" <?php echo 'a' === $icon_tag ? $this->get_render_attribute_string( 'link' ) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_render_attribute_string() returns pre-sanitized attribute markup ?>> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </<?php echo esc_attr( $icon_tag ); ?>> <?php } ``` > **Remove checklist:** > - `link` → remove if icon is decorative only > - `border` + `border_radius` + `box_shadow` → remove for plain icon-only use > - `icon_padding` → remove if icon has no background or border > - `hover_animation` → excluded by default **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( ! settings.selected_icon || ! settings.selected_icon.value ) { return; } var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' ); var tag = settings.link && settings.link.url ? 'a' : 'div'; // ✅ Escape the URL — it lands in an attribute via {{{ linkAttr }}} (see field-notes.md §2). var linkAttr = 'a' === tag ? 'href="' + _.escape( settings.link.url ) + '"' : ''; #> <{{{ tag }}} class="myplugin-icon" {{{ linkAttr }}}> {{{ iconHTML.value }}} </{{{ tag }}}> <?php } ``` -
widget-image-box.md 10.7 KB
# Widget Boilerplate — Image Box > **When to use this file:** Load whenever building a widget combining an image with a title and description. > Verified against `elementor/includes/widgets/image-box.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_image_box', [ 'label' => esc_html__( 'Image Box', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'image', [ 'label' => esc_html__( 'Choose Image', 'myplugin' ), 'type' => \Elementor\Controls_Manager::MEDIA, 'default' => [ 'url' => \Elementor\Utils::get_placeholder_image_src() ], 'dynamic' => [ 'active' => true ], ] ); // ✅ REQUIRED — get_attachment_image_html() in render() reads 'image_size' and // 'image_custom_dimension' generated by this group control. Without it, render() will fail. $this->add_group_control( \Elementor\Group_Control_Image_Size::get_type(), [ 'name' => 'image', // generates 'image_size' + 'image_custom_dimension' 'default' => 'large', 'separator' => 'none', ] ); $this->add_control( 'title_text', [ 'label' => esc_html__( 'Title & Description', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'This is the heading', 'myplugin' ), 'placeholder' => esc_html__( 'Enter your title', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'description_text', [ 'label' => '', 'type' => \Elementor\Controls_Manager::TEXTAREA, 'default' => esc_html__( 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis pulvinar.', 'myplugin' ), 'placeholder' => esc_html__( 'Enter your description', 'myplugin' ), 'rows' => 10, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], 'separator' => 'before', ] ); $this->add_control( 'title_size', [ 'label' => esc_html__( 'Title HTML Tag', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6', 'div' => 'div', 'span' => 'span', 'p' => 'p', ], 'default' => 'h3', ] ); $this->end_controls_section(); // ── STYLE: Image ────────────────────────────────────────── $this->start_controls_section( 'section_style_image', [ 'label' => esc_html__( 'Image', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'image_space', [ 'label' => esc_html__( 'Spacing', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 15, 'unit' => 'px' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'selectors' => [ '{{WRAPPER}} .myplugin-image-box-img' => 'margin-bottom: {{SIZE}}{{UNIT}};' ], ] ); $this->add_responsive_control( 'image_size', [ 'label' => esc_html__( 'Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'unit' => '%' ], 'size_units' => [ 'px', '%', 'vw' ], 'range' => [ '%' => [ 'min' => 5, 'max' => 100 ], 'px' => [ 'min' => 1, 'max' => 500 ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-image-box-img img' => 'width: {{SIZE}}{{UNIT}};' ], ] ); $this->add_responsive_control( 'image_border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-image-box-img img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->end_controls_section(); // ── STYLE: Content ──────────────────────────────────────── $this->start_controls_section( 'section_style_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'text_align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-image-box-content' => 'text-align: {{VALUE}};' ], ] ); $this->add_control( 'heading_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, 'separator' => 'before', ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-image-box-title, {{WRAPPER}} .myplugin-image-box-title a' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], 'selector' => '{{WRAPPER}} .myplugin-image-box-title', ] ); $this->add_control( 'heading_description', [ 'label' => esc_html__( 'Description', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, 'separator' => 'before', ] ); $this->add_control( 'description_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ], 'selectors' => [ '{{WRAPPER}} .myplugin-image-box-description' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'description_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], 'selector' => '{{WRAPPER}} .myplugin-image-box-description', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $tag = \Elementor\Utils::validate_html_tag( $settings['title_size'] ); $image_html = \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings ); $title = esc_html( $settings['title_text'] ); if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'link', $settings['link'] ); $title = '<a ' . $this->get_render_attribute_string( 'link' ) . '>' . $title . '</a>'; } ?> <div class="myplugin-image-box"> <div class="myplugin-image-box-img"> <?php echo wp_kses_post( $image_html ); ?> </div> <div class="myplugin-image-box-content"> <<?php echo esc_attr( $tag ); ?> class="myplugin-image-box-title"> <?php echo wp_kses_post( $title ); ?> </<?php echo esc_attr( $tag ); ?>> <?php if ( ! empty( $settings['description_text'] ) ) : ?> <p class="myplugin-image-box-description"><?php echo wp_kses_post( $settings['description_text'] ); ?></p> <?php endif; ?> </div> </div> <?php } ``` > **Remove checklist:** > - `link` → remove if the box is never clickable > - `image_border_radius` → remove for always-square images **content_template() skeleton:** ```php protected function content_template(): void { ?> <# var image = { id: settings.image.id, url: settings.image.url, size: settings.image_size, dimension: settings.image_custom_dimension, model: view.model, // ✅ view.model replaces deprecated view.getEditModel() (removed Elementor 3.x) }; var image_url = elementor.imagesManager.getImageUrl( image ); var tag = elementor.helpers.validateHTMLTag( settings.title_size ); // ✅ Escape user text/URL before it reaches {{{ title }}} (triple-brace needed for the <a> // wrapper) — otherwise it's an editor-context XSS wp.org rejects. See field-notes.md §2. var title = _.escape( settings.title_text ); if ( settings.link && settings.link.url ) { title = '<a href="' + _.escape( settings.link.url ) + '">' + title + '</a>'; } #> <div class="myplugin-image-box"> <div class="myplugin-image-box-img"><img src="{{ image_url }}" alt=""></div> <div class="myplugin-image-box-content"> <{{{ tag }}} class="myplugin-image-box-title">{{{ title }}}</{{{ tag }}}> <# if ( settings.description_text ) { #> <p class="myplugin-image-box-description">{{ settings.description_text }}</p> <# } #> </div> </div> <?php } ``` -
widget-image-carousel.md 14.3 KB
# Widget Boilerplate — Image Carousel > **When to use this file:** Load whenever building a widget showing a rotating image slider/carousel. > Verified against `elementor/includes/widgets/image-carousel.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > ✅ Relies on Swiper JS (bundled with Elementor) for carousel behaviour. --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_image_carousel', [ 'label' => esc_html__( 'Image Carousel', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'carousel', [ 'label' => esc_html__( 'Add Images', 'myplugin' ), 'type' => \Elementor\Controls_Manager::GALLERY, 'default' => [], 'dynamic' => [ 'active' => true ], ] ); $this->add_group_control( \Elementor\Group_Control_Image_Size::get_type(), [ 'name' => 'thumbnail', 'default' => 'medium' ] ); $this->add_responsive_control( 'slides_to_show', [ 'label' => esc_html__( 'Slides to Show', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => '3', 'options' => [ '1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6' ], ] ); $this->add_control( 'slides_to_scroll', [ 'label' => esc_html__( 'Slides to Scroll', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => '1', 'options' => [ '1' => '1', '2' => '2', '3' => '3' ], ] ); $this->add_control( 'image_carousel_link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'none', 'options' => [ 'none' => esc_html__( 'None', 'myplugin' ), 'file' => esc_html__( 'Media File', 'myplugin' ), 'custom' => esc_html__( 'Custom URL', 'myplugin' ), ], ] ); $this->add_control( 'link_to', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'placeholder' => esc_html__( 'https://your-link.com', 'myplugin' ), 'condition' => [ 'image_carousel_link' => 'custom' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'open_lightbox', [ 'label' => esc_html__( 'Lightbox', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'default', 'options' => [ 'default' => esc_html__( 'Default', 'myplugin' ), 'yes' => esc_html__( 'Yes', 'myplugin' ), 'no' => esc_html__( 'No', 'myplugin' ), ], 'condition' => [ 'image_carousel_link' => 'file' ], ] ); $this->end_controls_section(); // ── CONTENT: Additional Options ─────────────────────────── $this->start_controls_section( 'section_additional_options', [ 'label' => esc_html__( 'Additional Options', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'autoplay', [ 'label' => esc_html__( 'Autoplay', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ] ); $this->add_control( 'pause_on_hover', [ 'label' => esc_html__( 'Pause on Hover', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', 'condition' => [ 'autoplay' => 'yes' ], ] ); $this->add_control( 'autoplay_speed', [ 'label' => esc_html__( 'Autoplay Speed', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'default' => 5000, 'condition' => [ 'autoplay' => 'yes' ], ] ); $this->add_control( 'infinite', [ 'label' => esc_html__( 'Infinite Loop', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ] ); $this->add_control( 'transition', [ 'label' => esc_html__( 'Transition', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'slide', 'options' => [ 'slide' => esc_html__( 'Slide', 'myplugin' ), 'fade' => esc_html__( 'Fade', 'myplugin' ), ], ] ); $this->add_control( 'transition_speed', [ 'label' => esc_html__( 'Transition Speed', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'default' => 500, ] ); $this->add_control( 'navigation', [ 'label' => esc_html__( 'Navigation', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'arrows', 'options' => [ 'both' => esc_html__( 'Arrows and Dots', 'myplugin' ), 'arrows' => esc_html__( 'Arrows', 'myplugin' ), 'dots' => esc_html__( 'Dots', 'myplugin' ), 'none' => esc_html__( 'None', 'myplugin' ), ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_style_navigation', [ 'label' => esc_html__( 'Navigation', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'heading_arrows', [ 'label' => esc_html__( 'Arrows', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, ] ); $this->add_responsive_control( 'arrows_size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 20, 'unit' => 'px' ], 'range' => [ 'px' => [ 'min' => 10, 'max' => 100 ] ], 'selectors' => [ '{{WRAPPER}} .swiper-button-prev, {{WRAPPER}} .swiper-button-next' => 'font-size: {{SIZE}}{{UNIT}};' ], ] ); $this->add_control( 'arrows_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .swiper-button-prev, {{WRAPPER}} .swiper-button-next' => 'color: {{VALUE}};' ], ] ); $this->end_controls_section(); $this->start_controls_section( 'section_style_image', [ 'label' => esc_html__( 'Image', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'image_border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-carousel-image' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` > **Remove checklist:** > - `open_lightbox` → remove if carousel images don't link to media file > - `pause_on_hover` → remove if autoplay is always off > - Navigation style section → remove if navigation is always `none` **render() + content_template() skeleton:** ```php // ✅ REQUIRED: Declare Swiper as a dependency so Elementor enqueues it only on pages // using this widget. Swiper is bundled with Elementor — do NOT register it yourself. // Source: developers.elementor.com/docs/widgets/widget-dependencies/ public function get_script_depends(): array { return [ 'swiper' ]; } public function get_style_depends(): array { return [ 'swiper' ]; } public function has_widget_inner_wrapper(): bool { return false; } protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['carousel'] ) ) { return; } $show_arrows = in_array( $settings['navigation'], [ 'arrows', 'both' ], true ); $show_dots = in_array( $settings['navigation'], [ 'dots', 'both' ], true ); // ✅ Swiper is bundled with Elementor — declare 'swiper' as a script dependency // in get_script_depends() so Elementor loads it only on pages using this widget. $this->add_render_attribute( 'carousel-wrapper', [ 'class' => 'myplugin-image-carousel swiper', 'data-autoplay' => 'yes' === $settings['autoplay'] ? $settings['autoplay_speed'] : '0', 'data-loop' => 'yes' === $settings['infinite'] ? 'true' : 'false', 'data-slides-to-show' => $settings['slides_to_show'], 'data-slides-to-scroll'=> $settings['slides_to_scroll'], 'data-effect' => $settings['transition'], 'data-speed' => $settings['transition_speed'], 'data-pause-on-hover' => 'yes' === ( $settings['pause_on_hover'] ?? '' ) ? 'true' : 'false', ] ); ?> <div class="myplugin-carousel-wrapper"> <div <?php $this->print_render_attribute_string( 'carousel-wrapper' ); ?>> <div class="swiper-wrapper"> <?php foreach ( $settings['carousel'] as $attachment ) : $image_html = \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings, 'thumbnail', $attachment ); if ( empty( $image_html ) ) continue; $link_tag_open = ''; $link_tag_close = ''; if ( 'file' === $settings['image_carousel_link'] ) { $open_lb = esc_attr( $settings['open_lightbox'] ?? 'default' ); $link_tag_open = '<a href="' . esc_url( wp_get_attachment_url( $attachment['id'] ) ) . '" data-elementor-open-lightbox="' . $open_lb . '">'; $link_tag_close = '</a>'; } elseif ( 'custom' === $settings['image_carousel_link'] && ! empty( $settings['link_to']['url'] ) ) { $this->add_link_attributes( 'carousel-link', $settings['link_to'] ); $link_tag_open = '<a ' . $this->get_render_attribute_string( 'carousel-link' ) . '>'; $link_tag_close = '</a>'; } ?> <div class="swiper-slide"> <figure class="myplugin-carousel-image"> <?php echo $link_tag_open; // phpcs:ignore WordPress.Security.EscapeOutput ?> <?php echo wp_kses_post( $image_html ); ?> <?php echo $link_tag_close; // phpcs:ignore WordPress.Security.EscapeOutput ?> </figure> </div> <?php endforeach; ?> </div> <?php if ( $show_dots ) : ?> <div class="swiper-pagination"></div> <?php endif; ?> </div> <?php if ( $show_arrows ) : ?> <div class="swiper-button-prev"></div> <div class="swiper-button-next"></div> <?php endif; ?> </div> <?php } protected function content_template(): void { ?> <# if ( ! settings.carousel || ! settings.carousel.length ) { return; } var showArrows = -1 !== [ 'arrows', 'both' ].indexOf( settings.navigation ); var showDots = -1 !== [ 'dots', 'both' ].indexOf( settings.navigation ); #> <div class="myplugin-carousel-wrapper"> <div class="myplugin-image-carousel swiper"> <div class="swiper-wrapper"> <# _.each( settings.carousel, function( attachment ) { #> <div class="swiper-slide"> <figure class="myplugin-carousel-image"> <img src="{{ attachment.url }}" alt=""> </figure> </div> <# } ); #> </div> <# if ( showDots ) { #><div class="swiper-pagination"></div><# } #> </div> <# if ( showArrows ) { #> <div class="swiper-button-prev"></div> <div class="swiper-button-next"></div> <# } #> </div> <?php } ``` > **Script dependency note:** Add `'swiper'` to `get_script_depends()` so Elementor loads > the bundled Swiper JS only on pages containing this widget. > ⚠️ **Required: Swiper JS initialization.** The `.swiper` markup alone renders as a static list. > You MUST initialize Swiper via JS in your widget's frontend script. Declare `'swiper'` and > `'elementor-frontend'` as dependencies in `get_script_depends()`, then initialize per-instance > inside the `elementor/frontend/init` handler: > ```js > ( () => { > 'use strict'; > const init = ( scope ) => { > const el = scope.querySelector( '.myplugin-image-carousel.swiper' ); > if ( ! el ) return; > const nav = scope.dataset.navigation || 'none'; // read from data-* if needed > new Swiper( el, { > slidesPerView: 1, > loop: true, > navigation: ( nav === 'arrows' || nav === 'both' ) ? { > nextEl: scope.querySelector( '.swiper-button-next' ), > prevEl: scope.querySelector( '.swiper-button-prev' ), > } : false, > pagination: ( nav === 'dots' || nav === 'both' ) ? { > el: scope.querySelector( '.swiper-pagination' ), > clickable: true, > } : false, > // ✅ Respect prefers-reduced-motion — do not autoplay animations for users who opt out > autoplay: window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches > ? false > : { delay: 3000, disableOnInteraction: true }, > } ); > }; > > window.addEventListener( 'elementor/frontend/init', () => { > window.elementorFrontend.hooks.addAction( > 'frontend/element_ready/myplugin-widget.default', > ( $scope ) => { > if ( ! $scope || ! $scope[0] ) return; > init( $scope[0] ); > } > ); > } ); > } )(); > ``` > **Swiper version note:** Elementor bundles Swiper 8.x (class `.swiper`, NOT the legacy > `.swiper-container`). Do NOT register your own Swiper copy — use `'swiper'` as a dependency > to guarantee version consistency with Elementor's bundled copy. -
widget-image-gallery.md 6.9 KB
# Widget Boilerplate — Image Gallery > **When to use this file:** Load whenever building a widget that displays a grid of images. > Verified against `elementor/includes/widgets/image-gallery.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_gallery', [ 'label' => esc_html__( 'Image Gallery', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); // ✅ GALLERY control — stores array of [ 'id' => int, 'url' => string ] $this->add_control( 'wp_gallery', [ 'label' => esc_html__( 'Add Images', 'myplugin' ), 'type' => \Elementor\Controls_Manager::GALLERY, 'default' => [], 'dynamic' => [ 'active' => true ], ] ); $this->add_group_control( \Elementor\Group_Control_Image_Size::get_type(), [ 'name' => 'thumbnail', 'default' => 'medium', ] ); $this->add_responsive_control( 'gallery_columns', [ 'label' => esc_html__( 'Columns', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => '3', 'options' => [ '1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6' ], ] ); $this->add_control( 'gallery_link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'file', 'options' => [ 'file' => esc_html__( 'Media File', 'myplugin' ), 'none' => esc_html__( 'None', 'myplugin' ), ], ] ); $this->add_control( 'open_lightbox', [ 'label' => esc_html__( 'Lightbox', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'default', 'options' => [ 'default' => esc_html__( 'Default', 'myplugin' ), 'yes' => esc_html__( 'Yes', 'myplugin' ), 'no' => esc_html__( 'No', 'myplugin' ), ], 'condition' => [ 'gallery_link' => 'file' ], ] ); $this->add_control( 'gallery_rand', [ 'label' => esc_html__( 'Ordering', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => '', 'options' => [ '' => esc_html__( 'Default', 'myplugin' ), 'rand' => esc_html__( 'Random', 'myplugin' ), ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_gallery_images_style', [ 'label' => esc_html__( 'Images', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'columns_gap', [ 'label' => esc_html__( 'Columns Gap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'default' => [ 'size' => 10, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-gallery-item' => 'padding-right: calc( {{SIZE}}{{UNIT}}/2 ); padding-left: calc( {{SIZE}}{{UNIT}}/2 );' ], ] ); $this->add_responsive_control( 'rows_gap', [ 'label' => esc_html__( 'Rows Gap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'default' => [ 'size' => 10, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-gallery-item' => 'padding-bottom: {{SIZE}}{{UNIT}};' ], ] ); $this->add_responsive_control( 'image_border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-gallery-item img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return true when 'Random' ordering is selected — shuffle() produces different output // every render, so output caching must be bypassed to avoid freezing one random order forever. // Return false for fixed ordering (Default) to benefit from output caching. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return 'rand' === $this->get_settings( 'gallery_rand' ); } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['wp_gallery'] ) ) { return; } $gallery = $settings['wp_gallery']; if ( 'rand' === $settings['gallery_rand'] ) { shuffle( $gallery ); } ?> <div class="myplugin-gallery myplugin-gallery-columns-<?php echo esc_attr( $settings['gallery_columns'] ); ?>"> <?php foreach ( $gallery as $attachment ) : $image_html = \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings, 'thumbnail', $attachment ); if ( empty( $image_html ) ) continue; $link_tag_open = ''; $link_tag_close = ''; if ( 'file' === $settings['gallery_link'] ) { $link_tag_open = '<a href="' . esc_url( wp_get_attachment_url( $attachment['id'] ) ) . '" data-elementor-open-lightbox="' . esc_attr( $settings['open_lightbox'] ) . '">'; $link_tag_close = '</a>'; } ?> <div class="myplugin-gallery-item"> <?php echo $link_tag_open; // phpcs:ignore ?> <?php echo wp_kses_post( $image_html ); ?> <?php echo $link_tag_close; // phpcs:ignore ?> </div> <?php endforeach; ?> </div> <?php } ``` **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( ! settings.wp_gallery || ! settings.wp_gallery.length ) { return; } #> <div class="myplugin-gallery myplugin-gallery-columns-{{ settings.gallery_columns }}"> <# _.each( settings.wp_gallery, function( attachment ) { #> <div class="myplugin-gallery-item"> <img src="{{ attachment.url }}" alt=""> </div> <# } ); #> </div> <?php } ``` -
widget-image.md 17 KB
# Widget Boilerplate — Image > **When to use this file:** Load this boilerplate whenever building a widget that includes > a primary image element — hero images, thumbnails, team photos, logos, or any single focal > image with optional caption and link. > > Verified against `elementor/includes/widgets/image.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > Uses `Group_Control_Image_Size` + `get_attachment_image_html()` — the correct Elementor-native > approach. Never use a hardcoded SELECT or `wp_get_attachment_image()` directly. --- ### Image Widget Boilerplate Verified against `elementor/includes/widgets/image.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). **Critical difference from naive implementations:** Elementor uses `Group_Control_Image_Size` for image size — NOT a SELECT with hardcoded sizes. This group control handles all registered WordPress sizes plus custom dimensions automatically, and must be paired with `Group_Control_Image_Size::get_attachment_image_html()` in render(). Using `wp_get_attachment_image()` directly bypasses the custom dimension control entirely. ```php protected function register_controls(): void { // ========================================================= // TAB: CONTENT // ========================================================= $this->start_controls_section( 'section_image', [ 'label' => esc_html__( 'Image', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'image', [ 'label' => esc_html__( 'Choose Image', 'myplugin' ), 'type' => \Elementor\Controls_Manager::MEDIA, 'default' => [ // ✅ Shows Elementor's grey placeholder — keeps widget visible with no image set 'url' => \Elementor\Utils::get_placeholder_image_src(), ], 'dynamic' => [ 'active' => true ], ] ); // ✅ Group_Control_Image_Size generates two controls automatically: // '{name}_size' → SELECT of all registered WP image sizes + 'custom' // '{name}_custom_dimension' → IMAGE_DIMENSIONS inputs, shown only when size = 'custom' // The 'name' value here determines the control ID prefix — must match get_attachment_image_html() call. $this->add_group_control( \Elementor\Group_Control_Image_Size::get_type(), [ 'name' => 'image', // generates 'image_size' and 'image_custom_dimension' 'default' => 'large', 'separator' => 'none', ] ); $this->add_control( 'caption_source', [ 'label' => esc_html__( 'Caption', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'none', 'options' => [ 'none' => esc_html__( 'None', 'myplugin' ), 'attachment' => esc_html__( 'Attachment Caption', 'myplugin' ), 'custom' => esc_html__( 'Custom Caption', 'myplugin' ), ], ] ); $this->add_control( 'caption', [ 'label' => esc_html__( 'Custom Caption', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => '', 'placeholder' => esc_html__( 'Enter your image caption', 'myplugin' ), 'condition' => [ 'caption_source' => 'custom' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'link_to', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'none', 'options' => [ 'none' => esc_html__( 'None', 'myplugin' ), 'file' => esc_html__( 'Media File', 'myplugin' ), 'custom' => esc_html__( 'Custom URL', 'myplugin' ), ], ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link URL', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'placeholder' => esc_html__( 'https://your-link.com', 'myplugin' ), 'condition' => [ 'link_to' => 'custom' ], 'dynamic' => [ 'active' => true ], 'label_block' => true, ] ); // ✅ open_lightbox — control ID matches Elementor native exactly $this->add_control( 'open_lightbox', [ 'label' => esc_html__( 'Lightbox', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'default', 'options' => [ 'default' => esc_html__( 'Default', 'myplugin' ), 'yes' => esc_html__( 'Yes', 'myplugin' ), 'no' => esc_html__( 'No', 'myplugin' ), ], 'condition' => [ 'link_to' => 'file' ], ] ); $this->end_controls_section(); // ========================================================= // TAB: STYLE // ========================================================= $this->start_controls_section( 'section_style_image', [ 'label' => esc_html__( 'Image', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); // ✅ No 'default' — Elementor native leaves this empty (inherits document flow) $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};', ], ] ); // ✅ Width/Max Width/Height — size intentionally empty (no forced value), unit set only. // Empty size = browser/CSS default. Setting a size writes inline style and overrides cascade. $this->add_responsive_control( 'width', [ 'label' => esc_html__( 'Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'unit' => '%' ], 'size_units' => [ 'px', '%', 'vw', 'em', 'rem', 'custom' ], 'range' => [ 'px' => [ 'min' => 1, 'max' => 1200 ], '%' => [ 'min' => 1, 'max' => 100 ], 'vw' => [ 'min' => 1, 'max' => 100 ], ], 'selectors' => [ // ✅ Elementor native targets '{{WRAPPER}} img' directly — no wrapper class '{{WRAPPER}} img' => 'width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'max_width', [ 'label' => esc_html__( 'Max Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'unit' => '%' ], 'size_units' => [ 'px', '%', 'vw', 'em', 'rem', 'custom' ], 'range' => [ 'px' => [ 'min' => 1, 'max' => 1200 ], '%' => [ 'min' => 1, 'max' => 100 ], 'vw' => [ 'min' => 1, 'max' => 100 ], ], 'selectors' => [ '{{WRAPPER}} img' => 'max-width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'height', [ 'label' => esc_html__( 'Height', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'unit' => 'px' ], 'size_units' => [ 'px', 'vh', 'em', 'rem', 'custom' ], 'range' => [ 'px' => [ 'min' => 1, 'max' => 1200 ], 'vh' => [ 'min' => 1, 'max' => 100 ], ], 'selectors' => [ '{{WRAPPER}} img' => 'height: {{SIZE}}{{UNIT}}; object-fit: cover;', ], ] ); // ── Normal / Hover tabs ─────────────────────────────────── $this->start_controls_tabs( 'tabs_image_effects' ); $this->start_controls_tab( 'tab_image_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ), ] ); // ✅ No default — writing opacity:1 inline blocks CSS hover transitions $this->add_control( 'opacity', [ 'label' => esc_html__( 'Opacity', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0.00, 'max' => 1, 'step' => 0.01 ] ], 'selectors' => [ '{{WRAPPER}} img' => 'opacity: {{SIZE}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Css_Filter::get_type(), [ 'name' => 'css_filters', 'selector' => '{{WRAPPER}} img', ] ); $this->end_controls_tab(); $this->start_controls_tab( 'tab_image_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ), ] ); $this->add_control( 'opacity_hover', [ 'label' => esc_html__( 'Opacity', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0.00, 'max' => 1, 'step' => 0.01 ] ], 'selectors' => [ '{{WRAPPER}}:hover img' => 'opacity: {{SIZE}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Css_Filter::get_type(), [ 'name' => 'css_filters_hover', 'selector' => '{{WRAPPER}}:hover img', ] ); $this->add_control( 'hover_transition', [ 'label' => esc_html__( 'Transition Duration', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 3, 'step' => 0.1 ] ], 'selectors' => [ '{{WRAPPER}} img' => 'transition-duration: {{SIZE}}s;', ], ] ); // ⛔ DO NOT include hover_animation by default — loads animation CSS library. // Only uncomment if the client explicitly requires image hover animation. // // $this->add_control( 'hover_animation', [ // 'label' => esc_html__( 'Hover Animation', 'myplugin' ), // 'type' => \Elementor\Controls_Manager::HOVER_ANIMATION, // ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->add_group_control( \Elementor\Group_Control_Border::get_type(), [ 'name' => 'image_border', // ✅ Elementor native targets '{{WRAPPER}} img' directly 'selector' => '{{WRAPPER}} img', 'separator' => 'before', ] ); $this->add_responsive_control( 'image_border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Box_Shadow::get_type(), [ 'name' => 'image_box_shadow', 'selector' => '{{WRAPPER}} img', ] ); $this->end_controls_section(); } ``` **Matching render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); if ( empty( $settings['image']['url'] ) ) { return; } $has_caption = $this->has_caption( $settings ); $link = $this->get_link_url( $settings ); $this->add_render_attribute( 'wrapper', 'class', 'elementor-image' ); if ( $link ) { $this->add_link_attributes( 'link', $link ); $this->add_render_attribute( 'link', [ 'class' => 'elementor-clickable', 'data-elementor-open-lightbox' => $settings['open_lightbox'], ] ); } ?> <div <?php $this->print_render_attribute_string( 'wrapper' ); ?>> <?php if ( $has_caption ) : ?><figure class="wp-caption"><?php endif; ?> <?php if ( $link ) : ?><a <?php $this->print_render_attribute_string( 'link' ); ?>><?php endif; ?> <?php // ✅ ALWAYS use get_attachment_image_html() — not wp_get_attachment_image(). // This is the only method that correctly reads both 'image_size' and // 'image_custom_dimension' controls generated by Group_Control_Image_Size. // Output is WordPress-generated, escaped markup from Elementor's own helper. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted Elementor-generated <img> markup echo \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings ); ?> <?php if ( $link ) : ?></a><?php endif; ?> <?php if ( $has_caption ) : ?> <figcaption class="widget-image-caption wp-caption-text"> <?php echo esc_html( $this->get_caption( $settings ) ); ?> </figcaption> </figure> <?php endif; ?> </div> <?php } private function has_caption( array $settings ): bool { return ( ! empty( $settings['caption_source'] ) && 'none' !== $settings['caption_source'] ); } private function get_caption( array $settings ): string { if ( 'custom' === $settings['caption_source'] ) { return $settings['caption'] ?? ''; } if ( 'attachment' === $settings['caption_source'] && ! empty( $settings['image']['id'] ) ) { return (string) wp_get_attachment_caption( (int) $settings['image']['id'] ); } return ''; } private function get_link_url( array $settings ): ?array { if ( 'none' === $settings['link_to'] ) { return null; } if ( 'custom' === $settings['link_to'] ) { return ! empty( $settings['link']['url'] ) ? $settings['link'] : null; } return [ 'url' => $settings['image']['url'] ]; // 'file' } protected function content_template(): void { ?> <# if ( ! settings.image.url ) { return; } // ✅ Official Elementor docs use 'const' here — matches developers.elementor.com/docs/widgets/rendering-media/ const image = { id: settings.image.id, url: settings.image.url, size: settings.image_size, dimension: settings.image_custom_dimension, model: view.model, // ✅ view.model replaces deprecated view.getEditModel() (removed Elementor 3.x) }; const image_url = elementor.imagesManager.getImageUrl( image ); // ✅ Official docs use strict empty string check if ( '' === image_url ) { return; } const hasCaption = 'none' !== settings.caption_source; let link_url; if ( 'custom' === settings.link_to ) { // ✅ Official Elementor pattern — link.url used directly per official advanced example link_url = settings.link.url || ''; } if ( 'file' === settings.link_to ) { link_url = settings.image.url; } // ⛔ hover_animation excluded — uncomment if control is re-enabled: // var imgClass = settings.hover_animation ? 'elementor-animation-' + settings.hover_animation : ''; #> <div class="elementor-image"> <# if ( hasCaption ) { #><figure class="wp-caption"><# } #> <# if ( link_url ) { #> <a class="elementor-clickable" data-elementor-open-lightbox="{{ settings.open_lightbox }}" href="{{ link_url }}"> <# } #> <img src="{{ image_url }}" alt=""> <# if ( link_url ) { #></a><# } #> <# if ( hasCaption ) { #> <figcaption class="widget-image-caption wp-caption-text"> {{ 'custom' === settings.caption_source ? settings.caption : '' }} </figcaption> </figure> <# } #> </div> <?php } ``` > **Remove checklist:** > - `caption_source` + `caption` → remove if image never has a caption > - `link_to` + `link` + `open_lightbox` → remove if image is purely decorative > - `max_width` → remove if image always fills its container > - `height` → remove if image must maintain natural aspect ratio > - `image_border_radius` → remove for always-square/unrounded images > - `hover_transition` → remove if no CSS hover effect applied to the image > - `hover_animation` → **excluded by default** (commented out) > - Opacity controls (both tabs) → remove for always-fully-opaque images -
widget-menu-anchor.md 2.3 KB
# Widget Boilerplate — Menu Anchor > **When to use this file:** Load whenever building a widget that creates a named anchor for in-page navigation. > Verified against `elementor/includes/widgets/menu-anchor.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_anchor', [ 'label' => esc_html__( 'Menu Anchor', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'anchor', [ 'label' => esc_html__( 'Menu Anchor ID', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'placeholder' => esc_html__( 'For Example: About', 'myplugin' ), 'description' => esc_html__( 'This ID will be the CSS ID you will have to use without the # sign.', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $id = $this->get_settings_for_display()['anchor']; if ( ! empty( $id ) ) { echo '<div id="' . esc_attr( sanitize_html_class( $id ) ) . '"></div>'; } } ``` --- **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( settings.anchor ) { // ✅ Strip characters that are invalid in HTML id values (keep a-z A-Z 0-9 _ -) // Mirrors PHP-side sanitize_html_class() to keep editor preview consistent with frontend. var safeId = settings.anchor.replace( /[^a-zA-Z0-9_-]/g, '' ); #> <div id="{{ safeId }}" class="myplugin-menu-anchor"></div> <# } #> <?php } ``` -
widget-nested.md 13.4 KB
# Widget Guide — Nested Tabs & Nested Accordion > **When to use this file:** Load when building a **Nested** Tabs or Accordion widget — where > each panel is a real **Container** the user fills with any elements (not a single > rich-text/repeater field). This mirrors Elementor's native **Nested Tabs** (free, 3.10/3.15+) > and **Nested Accordion** (free, 3.19+). > > **Use the classic boilerplates instead** (`widget-tabs.md`, `widget-accordion.md`, > `widget-toggle.md`) when each panel only needs simple text/WYSIWYG content. Those are simpler, > fully self-contained `Widget_Base` widgets. Reach for **this** guide only when panels must hold > arbitrary nested widgets/containers. --- ## ⚠️ Read this first — Nested is a different, partly-internal API Nested widgets do **not** extend `\Elementor\Widget_Base`. They extend `\Elementor\Modules\NestedElements\Base\Widget_Nested_Base`, and each "panel" is a child **Container** managed by Elementor's Nested Elements editor module. That module's editor-side behaviour (keeping the title repeater in sync with the child containers when items are added/removed/reordered) is provided by Elementor's own JS handlers — the **third-party-facing parts of this API are still only partially documented.** What this means in practice: - The **PHP class structure below is stable and correct** for 3.x–4.x. - The **editor add/remove/reorder sync** relies on Elementor's `nested-elements` module. If you need fully custom editor behaviour, you are in semi-internal territory — pin your Elementor version and test on upgrades. - **Requirements:** the **Container** layout is the default in Elementor 4.x, and Nested Elements is stable. On older sites both must be enabled under Elementor → Settings → Features. Source: developers.elementor.com/docs/widgets/ · `elementor/modules/nested-tabs/` · `elementor/modules/nested-accordion/` (Elementor GitHub). --- ## Nested Tabs — class skeleton ```php // includes/class-myplugin-nested-tabs.php defined( 'ABSPATH' ) || exit; use Elementor\Controls_Manager; use Elementor\Repeater; use Elementor\Modules\NestedElements\Base\Widget_Nested_Base; use Elementor\Modules\NestedElements\Controls\Control_Nested_Repeater; class MyPlugin_Nested_Tabs extends Widget_Nested_Base { public function get_name(): string { return 'myplugin-nested-tabs'; } public function get_title(): string { return esc_html__( 'My Nested Tabs', 'myplugin' ); } public function get_icon(): string { return 'eicon-tabs'; } public function get_categories(): array { return [ 'general' ]; } // ✅ The default child containers created when the widget is dropped in. // Each entry is a 'container' element the user then fills with any widgets. protected function get_default_children_elements(): array { return [ [ 'elType' => 'container', 'settings' => [ '_title' => esc_html__( 'Tab #1', 'myplugin' ) ], 'elements' => [], ], [ 'elType' => 'container', 'settings' => [ '_title' => esc_html__( 'Tab #2', 'myplugin' ) ], 'elements' => [], ], ]; } // ✅ Which repeater control holds each item's title — Elementor uses this to keep the // title repeater and the child containers in sync. protected function get_default_repeater_title_setting_key(): string { return 'tab_title'; } // ✅ Title template for newly-added children (%d = index). protected function get_default_children_title(): string { return esc_html__( 'Tab #%d', 'myplugin' ); } // ✅ Selectors the editor uses to place the empty-state "add element" placeholder. protected function get_default_children_placeholder_selector(): string { return '.myplugin-nested-tabs__content'; } protected function get_default_children_container_placeholder_selector(): string { return '.e-con'; } protected function get_html_wrapper_class(): string { return 'myplugin-nested-tabs'; } protected function register_controls(): void { $this->start_controls_section( 'section_tabs', [ 'label' => esc_html__( 'Tabs', 'myplugin' ), 'tab' => Controls_Manager::TAB_CONTENT, ] ); $repeater = new Repeater(); $repeater->add_control( 'tab_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => Controls_Manager::TEXT, 'default' => esc_html__( 'Tab Title', 'myplugin' ), 'placeholder' => esc_html__( 'Tab Title', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); // ✅ Control_Nested_Repeater (NOT the plain REPEATER) — this is the repeater type that // syncs each row with a child container. 'tabs' is the items array; prevent_empty stops // the user deleting the last tab. $this->add_control( 'tabs', [ 'label' => esc_html__( 'Tabs Items', 'myplugin' ), 'type' => Control_Nested_Repeater::CONTROL_TYPE, 'fields' => $repeater->get_controls(), 'default' => [ [ 'tab_title' => esc_html__( 'Tab #1', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Tab #2', 'myplugin' ) ], ], 'title_field' => '{{{ tab_title }}}', 'prevent_empty' => true, ] ); $this->end_controls_section(); // Add TAB_STYLE sections for title/active/content colours + typography exactly as in // widget-tabs.md — every visual property must still be a control (SKILL.md §5). } public function has_widget_inner_wrapper(): bool { return false; } // ✅ Nested content can contain anything (forms, dynamic widgets) — leave caching off // unless you are certain every child is static. protected function is_dynamic_content(): bool { return true; } protected function render(): void { $settings = $this->get_settings_for_display(); $tabs = $settings['tabs'] ?? []; if ( empty( $tabs ) ) { return; } // ✅ Per-instance id prefix prevents aria-controls collisions across multiple widgets. $id_int = substr( $this->get_id(), 0, 6 ); ?> <div class="myplugin-nested-tabs"> <div class="myplugin-nested-tabs__headings" role="tablist" aria-orientation="horizontal"> <?php foreach ( $tabs as $index => $item ) : $n = $index + 1; $is_active = 1 === $n; $title_id = "myplugin-tab-title-{$id_int}{$n}"; $content_id = "myplugin-tab-content-{$id_int}{$n}"; ?> <button type="button" id="<?php echo esc_attr( $title_id ); ?>" class="myplugin-nested-tabs__title<?php echo $is_active ? ' myplugin-active' : ''; ?>" role="tab" aria-controls="<?php echo esc_attr( $content_id ); ?>" aria-selected="<?php echo $is_active ? 'true' : 'false'; ?>" tabindex="<?php echo $is_active ? '0' : '-1'; ?>"> <?php echo esc_html( $item['tab_title'] ); ?> </button> <?php endforeach; ?> </div> <div class="myplugin-nested-tabs__content"> <?php foreach ( $tabs as $index => $item ) : $n = $index + 1; $is_active = 1 === $n; $title_id = "myplugin-tab-title-{$id_int}{$n}"; $content_id = "myplugin-tab-content-{$id_int}{$n}"; ?> <div id="<?php echo esc_attr( $content_id ); ?>" class="myplugin-nested-tabs__panel<?php echo $is_active ? ' myplugin-active' : ''; ?>" role="tabpanel" aria-labelledby="<?php echo esc_attr( $title_id ); ?>" <?php echo $is_active ? '' : 'hidden'; ?>> <?php // ✅ THE KEY CALL: print the matching child Container by index. // Widget_Nested_Base maps repeater item N to child container N. $this->print_child( $index ); ?> </div> <?php endforeach; ?> </div> </div> <?php } // ✅ Editor preview (Backbone). Render the title buttons; child containers are injected by // Elementor's nested-elements editor handler, so the panel markup is minimal here. protected function content_template(): void { ?> <div class="myplugin-nested-tabs"> <div class="myplugin-nested-tabs__headings" role="tablist"> <# _.each( settings.tabs, function( item, index ) { var isActive = 0 === index; #> <button type="button" class="myplugin-nested-tabs__title{{ isActive ? ' myplugin-active' : '' }}" role="tab" aria-selected="{{ isActive ? 'true' : 'false' }}" tabindex="{{ isActive ? '0' : '-1' }}">{{ item.tab_title }}</button> <# } ); #> </div> <div class="myplugin-nested-tabs__content"></div> </div> <?php } } ``` --- ## Registration ```php // Nested widgets register through the normal hook. add_action( 'elementor/widgets/register', function( \Elementor\Widgets_Manager $manager ) { require_once MYPLUGIN_PATH . 'includes/class-myplugin-nested-tabs.php'; $manager->register( new MyPlugin_Nested_Tabs() ); } ); ``` --- ## Frontend interaction handler (required) The markup above is static until JS wires up tab switching. Register a frontend handler under `elementor/frontend/init` (same pattern as `js-css-standards.md`) — **without `strategy:defer`**: ```js window.addEventListener( 'elementor/frontend/init', () => { window.elementorFrontend.hooks.addAction( 'frontend/element_ready/myplugin-nested-tabs.default', ( $scope ) => { const root = $scope && $scope[0]; if ( ! root ) return; const titles = [ ...root.querySelectorAll( '.myplugin-nested-tabs__title' ) ]; const panels = [ ...root.querySelectorAll( '.myplugin-nested-tabs__panel' ) ]; const activate = ( i ) => { titles.forEach( ( t, n ) => { const on = n === i; t.classList.toggle( 'myplugin-active', on ); t.setAttribute( 'aria-selected', String( on ) ); t.tabIndex = on ? 0 : -1; } ); panels.forEach( ( p, n ) => { p.classList.toggle( 'myplugin-active', n === i ); p.toggleAttribute( 'hidden', n !== i ); } ); }; titles.forEach( ( title, i ) => { title.addEventListener( 'click', () => activate( i ) ); // ✅ ARIA APG Tabs keyboard support — arrows move focus, Enter/Space activate. title.addEventListener( 'keydown', ( e ) => { if ( e.key === 'ArrowRight' || e.key === 'ArrowDown' ) { e.preventDefault(); titles[ ( i + 1 ) % titles.length ].focus(); } else if ( e.key === 'ArrowLeft' || e.key === 'ArrowUp' ) { e.preventDefault(); titles[ ( i - 1 + titles.length ) % titles.length ].focus(); } else if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); activate( i ); } } ); } ); } ); } ); ``` --- ## Nested Accordion — what changes Nested Accordion shares the same `Widget_Nested_Base` structure. Differences from Nested Tabs: 1. **Markup:** use the native HTML disclosure pattern — each item is a `<details>`/`<summary>` pair (or a `<button aria-expanded>` + region). `<details>`/`<summary>` gives you keyboard + screen-reader support **for free**, so prefer it: ```php foreach ( $items as $index => $item ) : ?> <details class="myplugin-nested-accordion__item"<?php echo 0 === $index ? ' open' : ''; ?>> <summary class="myplugin-nested-accordion__title"><?php echo esc_html( $item['item_title'] ); ?></summary> <div class="myplugin-nested-accordion__panel"><?php $this->print_child( $index ); ?></div> </details> <?php endforeach; ``` 2. **Open behaviour:** Accordion = multiple panels may stay open (like `widget-toggle.md`), or add a `max_items_expended = 'one'` control and a small JS handler that closes siblings when one `<details>` opens (single-open, like `widget-accordion.md`). 3. **Icons:** expose `selected_icon` / `selected_active_icon` as **widget-level** `ICONS` controls and read them from `$settings`, not the repeater item — see the bug note in `widget-toggle.md`. 4. `get_default_repeater_title_setting_key()` → `'item_title'`; `get_html_wrapper_class()` → `'myplugin-nested-accordion'`; icon `eicon-accordion`. > If you use `<details>`/`<summary>`, you do **not** need the keyboard handler above — the browser > provides it. Only add JS for the optional single-open behaviour. --- > **Remove / adapt checklist:** > - Drop the frontend keyboard handler if you build the Accordion with `<details>`/`<summary>`. > - Add TAB_STYLE controls for every colour/spacing/typography value (do not hardcode visuals). > - Pin your Elementor version and re-test on upgrade — the nested-elements editor sync is > semi-internal API. -
widget-php-template.md 12.2 KB
# Widget Pattern — Render a PHP Template File > **When to use this file:** Load whenever building a widget whose markup lives in a > separate `.php` template file rather than being written inline in `render()`. > Common use cases: complex card layouts, CPT single views, reusable partials, > WooCommerce-style overrideable templates, or any widget needing theme/child-theme > template overrides. > > **Default choice:** If the inner layout should be **visually designed by the user** in > Elementor, use `widget-elementor-template.md` instead. This file is for code-defined PHP > markup only. > > **APIs used:** > - `load_template()` — WP core, `developer.wordpress.org/reference/functions/load_template/` > - `locate_template()` — WP core, `developer.wordpress.org/reference/functions/locate_template/` > - `get_template_part()` — WP core, `developer.wordpress.org/reference/functions/get_template_part/` --- ## When PHP template files make sense | Situation | Recommended approach | |---|---| | User designs the inner layout visually | `widget-elementor-template.md` ✅ | | Markup is fixed / developer-defined | **This file** ✅ | | Template must be overridable by child themes | **This file** ✅ | | Reusing the same markup in shortcodes or REST | **This file** ✅ | | Complex conditional markup that would clutter `render()` | **This file** ✅ | --- ## File structure ``` my-plugin/ ├── my-plugin.php ├── includes/ │ └── class-myplugin-widget.php └── templates/ └── widget-mywidget.php ← the PHP template file ``` --- ## Three resolution strategies Choose the strategy that fits your plugin's architecture: | Strategy | Template lookup order | Override support | Use when | |---|---|---|---| | **A — Plugin-only** | `plugin/templates/` only | ❌ No theme override | Templates are internal/non-public | | **B — Theme-overrideable** | Child theme → Parent theme → Plugin fallback | ✅ Full override | Distributing a public plugin | | **C — `get_template_part()`** | Active theme directory only | ✅ Theme-only | Widget lives inside a theme, not a plugin | --- ## Strategy A — Plugin-only template file ```php protected function render(): void { $settings = $this->get_settings_for_display(); $template = plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php'; // ✅ Verify the file exists before including — avoids fatal errors. if ( ! file_exists( $template ) ) { return; } // ✅ Pass $settings to the template via $args. // Do NOT use extract($settings) — pollutes scope with untrusted keys. // The template accesses data as $args['settings']['key']. $args = [ 'settings' => $settings, 'widget' => $this, ]; // ✅ load_template() sets up WP globals ($wp_query, $post, etc.) // inside the included file — same environment as standard WP templates. // Source: developer.wordpress.org/reference/functions/load_template/ // // Second param $load_once = false — allows the same partial to be included // multiple times (e.g. inside a repeater loop). Use true for singletons. load_template( $template, false, $args ); } ``` **Template file** (`plugin/templates/widget-mywidget.php`): ```php <?php defined( 'ABSPATH' ) || exit; /** * Available variables (passed via load_template $args): * * @var array $args['settings'] All widget settings from get_settings_for_display(). * @var object $args['widget'] The widget instance (\Elementor\Widget_Base). * * Override: copy to {theme}/myplugin/widget-mywidget.php */ $settings = $args['settings'] ?? []; $widget = $args['widget'] ?? null; if ( empty( $settings['title'] ) ) { return; } ?> <div class="myplugin-widget"> <h2 class="myplugin-widget__title"><?php echo esc_html( $settings['title'] ); ?></h2> <?php if ( ! empty( $settings['description'] ) ) : ?> <div class="myplugin-widget__desc"><?php echo wp_kses_post( $settings['description'] ); ?></div> <?php endif; ?> </div> ``` > ⚠️ **Escaping in template files:** > Always escape at the point of output inside the template file. > `get_settings_for_display()` does **NOT** auto-escape TEXT or TEXTAREA values. > Use `esc_html()` for plain text, `wp_kses_post()` for rich text that may contain > links or formatting, and `esc_url()` for URLs. --- ## Strategy B — Theme-overrideable template (plugin with public API) This pattern lets child/parent themes override the widget's template by placing a file at the same relative path inside their theme directory. Used by WooCommerce, Easy Digital Downloads, and similar plugins. ```php protected function render(): void { $settings = $this->get_settings_for_display(); // ✅ locate_template() searches: // 1. Child theme directory // 2. Parent theme directory // 3. Returns '' if not found in either // Source: developer.wordpress.org/reference/functions/locate_template/ $theme_template = locate_template( 'myplugin/widget-mywidget.php' ); // Theme override wins; otherwise fall back to the plugin's bundled template. $template = $theme_template ?: plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php'; if ( ! file_exists( $template ) ) { return; } load_template( $template, false, [ 'settings' => $settings, 'widget' => $this, ] ); } ``` **Applying a filter for full extensibility:** ```php protected function render(): void { $settings = $this->get_settings_for_display(); $default_template = plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php'; // ✅ Allow plugins/themes to completely replace the template path via filter. $template = apply_filters( 'myplugin_widget_mywidget_template', locate_template( 'myplugin/widget-mywidget.php' ) ?: $default_template, $settings ); if ( ! file_exists( $template ) ) { return; } load_template( $template, false, [ 'settings' => $settings, 'widget' => $this ] ); } ``` --- ## Strategy C — `get_template_part()` (theme-resident widget) Use this only when the widget lives inside a **theme** (not a plugin). `get_template_part()` only searches the active theme and its parent — it has no plugin fallback. ```php protected function render(): void { $settings = $this->get_settings_for_display(); // ✅ WP 5.5+ $args parameter passes variables to the template. // Inside the template, variables are available as $args['key']. // Source: developer.wordpress.org/reference/functions/get_template_part/ get_template_part( 'template-parts/widgets/mywidget', // slug: looks for template-parts/widgets/mywidget.php null, // name: if set, also looks for mywidget-{name}.php [ 'settings' => $settings, 'widget' => $this, ] ); } ``` **Template file** (`theme/template-parts/widgets/mywidget.php`): ```php <?php defined( 'ABSPATH' ) || exit; $settings = $args['settings'] ?? []; $widget = $args['widget'] ?? null; ?> <div class="myplugin-widget"> <h2><?php echo esc_html( $settings['title'] ?? '' ); ?></h2> </div> ``` --- ## Required widget methods ```php public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false if the template file renders static content identical // for all users — enables Elementor output caching. // Return true if the template uses get_the_ID(), is_user_logged_in(), // current_user_can(), or any per-user/per-session logic. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; // change to true if template uses dynamic/user-specific data } ``` --- ## content_template() skeleton PHP template files cannot run in the JS `content_template()`. Use a representative placeholder that mirrors the visual structure so the editor preview matches the frontend: ```php protected function content_template(): void { ?> <# if ( ! settings.title ) { return; } #> <div class="myplugin-widget"> <h2 class="myplugin-widget__title">{{ settings.title }}</h2> <# if ( settings.description ) { #> <div class="myplugin-widget__desc">{{ settings.description }}</div> <# } #> </div> <?php } ``` > **Note:** If the PHP template contains complex logic that cannot be replicated in JS, it is > acceptable for `content_template()` to output a simplified preview. Elementor's own Post > Content widget uses this approach. Use `{{ }}` (escaped) for user TEXT values — reserve `{{{ }}}` > for Elementor-generated HTML only (see field-notes.md §2). `{{ }}` renders correctly with no > apostrophe corruption (the browser decodes the escaped entity on display). --- ## Outputting the rendered template as a string (for hooks/filters) When you need the template output as a string rather than echoing it directly — for example to pass it to a filter or cache it: ```php private function get_template_html( array $settings ): string { ob_start(); $template = plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php'; if ( file_exists( $template ) ) { load_template( $template, false, [ 'settings' => $settings, 'widget' => $this ] ); } return ob_get_clean() ?: ''; } protected function render(): void { $settings = $this->get_settings_for_display(); $html = $this->get_template_html( $settings ); if ( empty( $html ) ) { return; } // ✅ The HTML is generated by our own template (already escaped at output) — // mark with phpcs ignore so the sniffer does not flag the echo. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $html; } ``` --- ## Critical gotchas **1. Never use `include` or `require` directly** `load_template()` sets up all WordPress globals (`$post`, `$wp_query`, `$id`, etc.) inside the included file, exactly as WordPress does for standard templates. A bare `include` skips this setup, causing WP template tags to fail or return wrong data. **2. `$args` key — not extracted** `load_template()` does NOT extract `$args` into variables. Inside the template, all passed values live in `$args['key']`, not `$key` directly. Document this in every template file header. **3. `$load_once = false` for repeated partials** If the same template is used in a loop (e.g. a posts grid widget rendering each card), pass `false` as the second parameter to `load_template()`. The default `true` uses `require_once`, so only the first card would render. **4. Path traversal — never pass user input as a template name** `locate_template()` does not sanitise template names. Never interpolate control values directly into template paths. Use a fixed allowlist map instead: ```php // ❌ NEVER do this — path traversal attack vector $template = locate_template( $settings['template_name'] . '.php' ); // ✅ Use a fixed allowlist map $allowed = [ 'style-a' => 'myplugin/widget-mywidget-a.php', 'style-b' => 'myplugin/widget-mywidget-b.php', ]; $tpl_slug = $allowed[ $settings['template_style'] ] ?? $allowed['style-a']; $template = locate_template( $tpl_slug ) ?: plugin_dir_path( __FILE__ ) . 'templates/' . basename( $tpl_slug ); ``` **5. Child theme wins over parent theme** `locate_template()` checks the child theme first. If a child-theme user adds `myplugin/widget-mywidget.php` to their theme, it takes precedence over both the parent theme and the plugin fallback. --- ## Making templates overridable — developer documentation comment Add a comment to the top of your plugin's main file so third-party developers know where to place overrides: ```php /** * Template override: * To override widget templates, copy the file from: * my-plugin/templates/widget-mywidget.php * to: * {your-theme}/myplugin/widget-mywidget.php * Child-theme overrides take precedence over parent-theme overrides. */ ``` --- > **Remove checklist:** > - Strategy B filter (`myplugin_widget_mywidget_template`) → remove for internal plugins. > - `ob_start()` wrapper → only needed when returning HTML as a string. > - `$args['widget']` → remove if the template does not need widget methods. > - `description` control → remove if the widget has only one content field. -
widget-progress.md 6.4 KB
# Widget Boilerplate — Progress Bar > **When to use this file:** Load whenever building a widget showing a percentage-based progress bar. > Verified against `elementor/includes/widgets/progress.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_progress', [ 'label' => esc_html__( 'Progress Bar', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'placeholder' => esc_html__( 'Enter your title', 'myplugin' ), 'default' => esc_html__( 'Design', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'percent', [ 'label' => esc_html__( 'Percentage', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 50, 'unit' => '%' ], 'range' => [ '%' => [ 'min' => 0, 'max' => 100 ] ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'display_percentage', [ 'label' => esc_html__( 'Display Percentage', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ] ); $this->add_control( 'inner_text', [ 'label' => esc_html__( 'Inner Text', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'placeholder' => esc_html__( '75 of 100 points', 'myplugin' ), 'default' => esc_html__( '75 of 100 points', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_progress_style', [ 'label' => esc_html__( 'Progress Bar', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'bar_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-bar-fill' => 'background-color: {{VALUE}};' ], ] ); $this->add_control( 'bar_bg_color', [ 'label' => esc_html__( 'Background Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-progress-bar' => 'background-color: {{VALUE}};' ], ] ); $this->add_responsive_control( 'bar_height', [ 'label' => esc_html__( 'Height', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 1, 'max' => 200 ] ], 'default' => [ 'size' => 20, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-progress-bar' => 'height: {{SIZE}}{{UNIT}};' ], ] ); $this->add_responsive_control( 'border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-progress-bar, {{WRAPPER}} .myplugin-bar-fill' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $pct = min( 100, (float) ( $settings['percent']['size'] ?? 0 ) ); ?> <div class="myplugin-progress"> <?php if ( ! empty( $settings['title'] ) ) : ?> <div class="myplugin-progress-title"> <span><?php echo esc_html( $settings['title'] ); ?></span> <?php if ( 'yes' === $settings['display_percentage'] ) : ?> <span class="myplugin-progress-percentage"><?php echo esc_html( $pct ); ?>%</span> <?php endif; ?> </div> <?php endif; ?> <div class="myplugin-progress-bar" role="progressbar" aria-valuenow="<?php echo esc_attr( $pct ); ?>" aria-valuemin="0" aria-valuemax="100"> <div class="myplugin-bar-fill" style="width: <?php echo esc_attr( $pct ); ?>%"> <?php if ( ! empty( $settings['inner_text'] ) ) : ?> <span class="myplugin-bar-inner-text"><?php echo esc_html( $settings['inner_text'] ); ?></span> <?php endif; ?> </div> </div> </div> <?php } ``` **content_template() skeleton:** ```php protected function content_template(): void { ?> <# var pct = Math.min( 100, parseFloat( settings.percent && settings.percent.size || 0 ) ); #> <div class="myplugin-progress"> <# if ( settings.title ) { #> <div class="myplugin-progress-title"> <span>{{ settings.title }}</span> <# if ( 'yes' === settings.display_percentage ) { #> <span class="myplugin-progress-percentage">{{ pct }}%</span> <# } #> </div> <# } #> <div class="myplugin-progress-bar" role="progressbar" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"> <div class="myplugin-bar-fill" style="width:{{ pct }}%"> <# if ( settings.inner_text ) { #> <span class="myplugin-bar-inner-text">{{ settings.inner_text }}</span> <# } #> </div> </div> </div> <?php } ``` -
widget-rating.md 8.6 KB
# Widget Boilerplate — Rating > **When to use this file:** Load whenever building a structured data / schema-ready rating widget. > Verified against `elementor/includes/widgets/rating.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > ⚠️ This is the newer Rating widget (added ~3.17) — distinct from Star Rating. It supports > schema markup and fractional ratings. Use Star Rating for purely decorative displays. --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_rating', [ 'label' => esc_html__( 'Rating', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'rating_type', [ 'label' => esc_html__( 'Rating Type', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'number', 'options' => [ 'number' => esc_html__( 'Number', 'myplugin' ), 'percent' => esc_html__( 'Percent', 'myplugin' ), ], ] ); $this->add_control( 'rating', [ 'label' => esc_html__( 'Rating', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'min' => 0, 'max' => 10, 'step' => 0.1, 'default' => 5, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'fa4compatibility' => 'icon', 'default' => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ], 'separator' => 'before', ] ); $this->add_control( 'icon_count', [ 'label' => esc_html__( 'Icon Count', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'min' => 1, 'max' => 10, 'default' => 5, ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_style_rating', [ 'label' => esc_html__( 'Rating', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ], ] ); $this->add_responsive_control( 'icon_size', [ 'label' => esc_html__( 'Icon Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 10, 'max' => 100 ] ], 'default' => [ 'size' => 20, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-rating-icon i' => 'font-size: {{SIZE}}{{UNIT}};', '{{WRAPPER}} .myplugin-rating-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'icon_gap', [ 'label' => esc_html__( 'Icon Gap', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 30 ] ], 'default' => [ 'size' => 4, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-rating-wrapper' => 'gap: {{SIZE}}{{UNIT}};', ], ] ); $this->add_control( 'icon_color', [ 'label' => esc_html__( 'Marked Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '#f0ad4e', 'selectors' => [ '{{WRAPPER}} .myplugin-rating-icon--full i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-rating-icon--full svg' => 'fill: {{VALUE}};', ], 'separator' => 'before', ] ); $this->add_control( 'icon_unmarked_color', [ 'label' => esc_html__( 'Unmarked Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '#d0d0d0', 'selectors' => [ '{{WRAPPER}} .myplugin-rating-icon--empty i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-rating-icon--empty svg' => 'fill: {{VALUE}};', ], ] ); $this->end_controls_section(); } ``` **render() + content_template() skeleton:** ```php public function has_widget_inner_wrapper(): bool { return false; } protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $rating = (float) ( $settings['rating'] ?? 0 ); $icon_count = (int) ( $settings['icon_count'] ?? 5 ); // Normalise to 0–icon_count range if ( 'percent' === $settings['rating_type'] ) { $rating = ( $rating / 100 ) * $icon_count; } else { $rating = min( $rating, 10 ); // max scale is 10 } $full_icons = floor( $rating ); $partial = $rating - $full_icons; // fractional part 0–1 $empty_icons = $icon_count - $full_icons - ( $partial > 0 ? 1 : 0 ); ?> <div class="myplugin-rating" role="img" aria-label="<?php echo esc_attr( round( $rating, 1 ) . '/' . $icon_count ); ?>"> <div class="myplugin-rating__wrapper"> <?php // Full icons for ( $i = 0; $i < $full_icons; $i++ ) : echo '<span class="myplugin-rating-icon myplugin-rating-icon--full">'; \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] ); echo '</span>'; endfor; // Partial icon — clip span to fractional width. // ✅ Required CSS for partial clip to work: // .myplugin-rating-icon--partial { position: relative; overflow: hidden; display: inline-block; } // .myplugin-rating-icon--partial i, // .myplugin-rating-icon--partial svg { position: absolute; left: 0; } // Without overflow:hidden the full icon shows even when width is e.g. 30%. if ( $partial > 0 ) : echo '<span class="myplugin-rating-icon myplugin-rating-icon--partial" style="width:' . esc_attr( round( $partial * 100 ) ) . '%">'; \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] ); echo '</span>'; endif; // Empty icons for ( $i = 0; $i < $empty_icons; $i++ ) : echo '<span class="myplugin-rating-icon myplugin-rating-icon--empty">'; \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] ); echo '</span>'; endfor; ?> </div> </div> <?php } protected function content_template(): void { ?> <# var rating = parseFloat( settings.rating ) || 0; var iconCount = parseInt( settings.icon_count ) || 5; if ( 'percent' === settings.rating_type ) { rating = ( rating / 100 ) * iconCount; } else { rating = Math.min( rating, 10 ); } var full = Math.floor( rating ); var partial = rating - full; var empty = iconCount - full - ( partial > 0 ? 1 : 0 ); var iconHTML = elementor.helpers.renderIcon( view, settings.icon, { 'aria-hidden': true }, 'i', 'object' ); var icon = iconHTML && iconHTML.value ? iconHTML.value : '<i class="fas fa-star" aria-hidden="true"></i>'; // ✅ FA fallback, not eicon-star (eicons not reliable on frontend) var stars = ''; for ( var i = 0; i < full; i++ ) { stars += '<span class="myplugin-rating-icon myplugin-rating-icon--full">' + icon + '</span>'; } if ( partial > 0 ) { stars += '<span class="myplugin-rating-icon myplugin-rating-icon--partial" style="width:' + Math.round(partial*100) + '%">' + icon + '</span>'; } for ( var j = 0; j < empty; j++ ) { stars += '<span class="myplugin-rating-icon myplugin-rating-icon--empty">' + icon + '</span>'; } #> <div class="myplugin-rating"> <div class="myplugin-rating__wrapper">{{{ stars }}}</div> </div> <?php } ``` -
widget-read-more.md 2 KB
# Widget Boilerplate — Read More > **When to use this file:** Load whenever building a widget that inserts WordPress's `<!--more-->` tag for archive excerpts. > Verified against `elementor/includes/widgets/read-more.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > This widget has **no controls** — it only renders the WordPress `more` tag. --- ```php protected function register_controls(): void { // No controls — this widget only inserts the WordPress <!--more--> tag } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ MUST be true — this widget mutates the global $more variable which controls how // the_content() behaves for the current request. Output caching would freeze the $more=0 // side-effect at cache-build time and not re-apply it on cached responses, causing full // post content to appear instead of truncating at the <!--more--> marker. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return true; } protected function render(): void { // ✅ The <!--more--> tag splits post content on ARCHIVE/LOOP pages (index, category, tag). // Setting global $more = 0 tells the_content() to stop output at the <!--more--> marker. // On singular pages the_content() always shows full content — $more has no effect there. // Do NOT add an is_singular() check here: the widget must run on archive pages, which // are the pages where <!--more--> is actually respected. global $more; $more = 0; // phpcs:ignore WordPress.WP.GlobalVariablesOverride } ``` --- **content_template() skeleton:** ```php protected function content_template(): void { // Read More has no frontend preview — it only affects archive excerpt splitting. } ``` -
widget-shortcode.md 2.8 KB
# Widget Boilerplate — Shortcode > **When to use this file:** Load whenever building a widget that outputs a WordPress shortcode. > Verified against `elementor/includes/widgets/shortcode.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'shortcode_section', [ 'label' => esc_html__( 'Shortcode', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'shortcode', [ 'label' => esc_html__( 'Enter your shortcode', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXTAREA, 'default' => '', 'placeholder' => '[shortcode]', 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ MUST be true — do_shortcode() may execute shortcodes that output user-specific // content (WooCommerce cart totals, membership gates, nonces, logged-in user data, etc.). // Returning false would allow Elementor's output cache to serve one user's shortcode // output to a different user. Always return true for shortcode widgets. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return true; } protected function render(): void { $settings = $this->get_settings_for_display(); $shortcode = do_shortcode( shortcode_unautop( $settings['shortcode'] ) ); // ✅ wp_kses_post() is intentionally used here — it strips <script> tags and other // unsafe markup from shortcode output. Most shortcodes should enqueue scripts via // wp_enqueue_script(), not inline them. If a specific shortcode genuinely requires // unfiltered output, use the HTML widget instead (which requires unfiltered_html cap). echo '<div class="myplugin-shortcode">' . wp_kses_post( $shortcode ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shortcode output is kses-filtered above } ``` --- **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( settings.shortcode ) { #> <div class="myplugin-shortcode"> <p class="myplugin-shortcode-placeholder"> <?php echo esc_html__( '[Shortcode placeholder — renders on frontend]', 'myplugin' ); ?> <code>{{ settings.shortcode }}</code> </p> </div> <# } #> <?php } ``` -
widget-sidebar.md 2.2 KB
# Widget Boilerplate — Sidebar > **When to use this file:** Load whenever building a widget that outputs a registered WordPress sidebar. > Verified against `elementor/includes/widgets/sidebar.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_sidebar', [ 'label' => esc_html__( 'Sidebar', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); // ✅ Build options list from registered sidebars at registration time $sidebars_options = []; if ( isset( $GLOBALS['wp_registered_sidebars'] ) ) { foreach ( $GLOBALS['wp_registered_sidebars'] as $sidebar_id => $sidebar ) { $sidebars_options[ $sidebar_id ] = $sidebar['name']; } } $this->add_control( 'sidebar', [ 'label' => esc_html__( 'Choose Sidebar', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => array_key_first( $sidebars_options ) ?? '', 'options' => $sidebars_options, ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ MUST be true — dynamic_sidebar() outputs registered sidebar widgets which may // include user-specific content (login forms, cart widgets, recent posts etc.). // Returning false would cache one user's sidebar output and serve it to all visitors. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return true; } protected function render(): void { $sidebar = $this->get_settings_for_display()['sidebar']; if ( ! empty( $sidebar ) && is_active_sidebar( $sidebar ) ) { dynamic_sidebar( $sidebar ); } } ``` --- **content_template() skeleton:** ```php protected function content_template(): void { // Sidebar cannot be previewed in the editor — dynamic PHP output only. } ``` -
widget-social-icons.md 10.1 KB
# Widget Boilerplate — Social Icons > **When to use this file:** Load whenever building a widget showing a row of social media icon links. > Verified against `elementor/includes/widgets/social-icons.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_social_icon', [ 'label' => esc_html__( 'Social Icons', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $repeater = new \Elementor\Repeater(); $repeater->add_control( 'social_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'default' => [ 'value' => 'fab fa-wordpress', 'library' => 'fa-brands' ], ] ); $repeater->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'default' => [ 'is_external' => 'true' ], 'dynamic' => [ 'active' => true ], ] ); $repeater->add_control( 'item_icon_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'default', 'options' => [ // 'default' = inherit the global Primary/Hover colors from the Style tab. // (This boilerplate does not implement per-brand "official" colors — that would // require a full brand-color lookup table. Use 'custom' for a per-item color.) 'default' => esc_html__( 'Default', 'myplugin' ), 'custom' => esc_html__( 'Custom', 'myplugin' ), ], ] ); // ✅ Per-item color, shown only when item_icon_color = 'custom'. The {{CURRENT_ITEM}} // token resolves to `.elementor-repeater-item-{id}` — which render() must add to the // <a> (see below) for this selector to target the right item. $repeater->add_control( 'item_icon_primary_color', [ 'label' => esc_html__( 'Custom Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'condition' => [ 'item_icon_color' => 'custom' ], 'selectors' => [ '{{WRAPPER}} {{CURRENT_ITEM}} i' => 'color: {{VALUE}};', '{{WRAPPER}} {{CURRENT_ITEM}} svg' => 'fill: {{VALUE}};', ], ] ); $this->add_control( 'social_icon_list', [ 'label' => esc_html__( 'Social Icons', 'myplugin' ), 'type' => \Elementor\Controls_Manager::REPEATER, 'fields' => $repeater->get_controls(), 'default' => [ [ 'social_icon' => [ 'value' => 'fab fa-facebook', 'library' => 'fa-brands' ], 'link' => [ 'url' => 'https://facebook.com', 'is_external' => 'true' ] ], // ✅ Twitter rebranded to X in July 2023. Font Awesome 6.4+ ships 'fa-x-twitter' // (fa-brands). Elementor bundles Font Awesome 6.x — fa-x-twitter is available. // fa-twitter still renders (FA keeps it as an alias) but fa-x-twitter is correct. [ 'social_icon' => [ 'value' => 'fab fa-x-twitter', 'library' => 'fa-brands' ], 'link' => [ 'url' => 'https://x.com', 'is_external' => 'true' ] ], [ 'social_icon' => [ 'value' => 'fab fa-instagram','library' => 'fa-brands' ], 'link' => [ 'url' => 'https://instagram.com','is_external' => 'true' ] ], ], 'title_field' => '<# var migrated = "undefined" !== typeof __fa4_migrated, icon = ( migrated || ! icon ) ? social_icon.value : icon; #>{{{ icon }}}', ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_social_style', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ], ] ); $this->add_responsive_control( 'icon_size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 6, 'max' => 300 ] ], 'default' => [ 'size' => 25, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-social-icon' => 'font-size: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'icon_spacing', [ 'label' => esc_html__( 'Spacing', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'default' => [ 'size' => 5, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-social-icon:not(:last-child)' => 'margin-right: {{SIZE}}{{UNIT}};', ], ] ); $this->start_controls_tabs( 'tabs_social_icon_style' ); $this->start_controls_tab( 'tab_social_icon_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ) ] ); $this->add_control( 'icon_primary_color', [ 'label' => esc_html__( 'Primary Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-social-icon i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-social-icon svg' => 'fill: {{VALUE}};', ], ] ); $this->end_controls_tab(); $this->start_controls_tab( 'tab_social_icon_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ) ] ); $this->add_control( 'hover_primary_color', [ 'label' => esc_html__( 'Primary Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-social-icon:hover i' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-social-icon:hover svg' => 'fill: {{VALUE}};', ], ] ); $this->end_controls_tab(); $this->end_controls_tabs(); $this->add_responsive_control( 'border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%', 'em', 'rem' ], 'selectors' => [ '{{WRAPPER}} .myplugin-social-icon' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], 'separator' => 'before', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); ?> <div class="myplugin-social-icons"> <?php foreach ( $settings['social_icon_list'] as $item ) : if ( ! empty( $item['link']['url'] ) ) { $this->add_link_attributes( 'social-icon-' . $item['_id'], $item['link'] ); } // ✅ ACCESSIBILITY: the icon is aria-hidden, so without this the link has NO // accessible name (WCAG 2.4.4 / 4.1.2 failure — screen readers announce an empty // link). Derive a readable label from the icon class (e.g. "fab fa-x-twitter" → // "X-twitter"), mirroring how Elementor's native widget adds an .elementor-screen-only // label. Hide it visually with a clip utility class (position:absolute; width:1px; etc.). $icon_value = $item['social_icon']['value'] ?? ''; $sr_label = $icon_value ? ucfirst( preg_replace( '/^.*fa-/', '', $icon_value ) ) : esc_html__( 'Social link', 'myplugin' ); ?> <a class="myplugin-social-icon elementor-repeater-item-<?php echo esc_attr( $item['_id'] ); ?>" <?php $this->print_render_attribute_string( 'social-icon-' . $item['_id'] ); ?>> <?php \Elementor\Icons_Manager::render_icon( $item['social_icon'], [ 'aria-hidden' => 'true' ] ); ?> <span class="myplugin-screen-only"><?php echo esc_html( $sr_label ); ?></span> </a> <?php endforeach; ?> </div> <?php } ``` **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( ! settings.social_icon_list || ! settings.social_icon_list.length ) { return; } #> <div class="myplugin-social-icons"> <# _.each( settings.social_icon_list, function( item ) { var iconHTML = elementor.helpers.renderIcon( view, item.social_icon, { 'aria-hidden': true }, 'i', 'object' ); // ✅ Official Elementor pattern — link.url used directly per official advanced example var url = item.link && item.link.url ? item.link.url : '#'; #> <a class="myplugin-social-icon elementor-repeater-item-{{ item._id }}" href="{{ url }}">{{{ iconHTML.value }}}</a> <# } ); #> </div> <?php } ``` -
widget-spacer.md 1.9 KB
# Widget Boilerplate — Spacer > **When to use this file:** Load whenever building a widget that adds vertical space between elements. > Verified against `elementor/includes/widgets/spacer.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_spacer', [ 'label' => esc_html__( 'Spacer', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_responsive_control( 'space', [ 'label' => esc_html__( 'Space', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'size' => 50, 'unit' => 'px' ], 'size_units' => [ 'px', 'em', 'rem', 'vh', '%', 'custom' ], 'range' => [ 'px' => [ 'min' => 0, 'max' => 500 ] ], 'selectors' => [ '{{WRAPPER}} .myplugin-spacer' => 'height: {{SIZE}}{{UNIT}};', ], ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { echo '<div class="myplugin-spacer"></div>'; } protected function content_template(): void { ?> <div class="myplugin-spacer" style="height: {{ settings.space.size }}{{ settings.space.unit }};"></div> <?php } ``` -
widget-star-rating.md 9.5 KB
# Widget Boilerplate — Star Rating > **When to use this file:** Load whenever building a widget that displays a star (or custom icon) rating. > Verified against `elementor/includes/widgets/star-rating.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { $this->start_controls_section( 'section_rating', [ 'label' => esc_html__( 'Rating', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'rating_scale', [ 'label' => esc_html__( 'Rating Scale', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ '5' => '0-5', '10' => '0-10' ], 'default' => '5', ] ); $this->add_control( 'rating', [ 'label' => esc_html__( 'Rating', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'min' => 0, 'max' => 10, 'step' => 0.1, 'default' => 5, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'star_style', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ 'star_fontawesome' => esc_html__( 'Font Awesome', 'myplugin' ), 'star_unicode' => esc_html__( 'Unicode', 'myplugin' ), ], 'default' => 'star_fontawesome', 'prefix_class' => 'elementor--star-style-', 'separator' => 'before', ] ); $this->add_control( 'unmarked_star_style', [ 'label' => esc_html__( 'Unmarked Style', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'solid' => [ 'title' => esc_html__( 'Solid', 'myplugin' ), 'icon' => 'eicon-star' ], 'outline' => [ 'title' => esc_html__( 'Outline', 'myplugin' ), 'icon' => 'eicon-star-o' ], ], 'default' => 'solid', ] ); $this->add_control( 'title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'separator' => 'before', 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_stars_style', [ 'label' => esc_html__( 'Stars', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ], ] ); $this->add_responsive_control( 'icon_size', [ 'label' => esc_html__( 'Icon Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 100 ] ], 'default' => [ 'size' => 20, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-star-rating' => 'font-size: {{SIZE}}{{UNIT}};' ], ] ); $this->add_control( 'star_color', [ 'label' => esc_html__( 'Marked Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '#f0ad4e', 'selectors' => [ '{{WRAPPER}} .myplugin-star-full i' => 'color: {{VALUE}};' ], 'separator' => 'before', ] ); $this->add_control( 'empty_star_color', [ 'label' => esc_html__( 'Unmarked Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '#d0d0d0', 'selectors' => [ '{{WRAPPER}} .myplugin-star-empty i' => 'color: {{VALUE}};' ], ] ); $this->end_controls_section(); } ``` --- **render() + content_template() skeleton:** ```php public function has_widget_inner_wrapper(): bool { return false; } protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $rating = (float) ( $settings['rating'] ?? 0 ); $max = (int) ( $settings['rating_scale'] ?? 5 ); $rating = min( $rating, $max ); // ✅ BUG FIX: Do NOT use hardcoded `eicon-star` / `eicon-star-o` class strings here. // `eicon-*` are Elementor's EDITOR icon font — the eicons stylesheet is NOT reliably // loaded on the frontend (especially when "Inline Font Icons" experiment is active, // CSS/JS optimisation is on, or Remove Unused CSS tools prune it). This is a confirmed // Elementor core bug (github.com/elementor/elementor/issues/18793). // // For a custom star rating widget, define ICONS controls for the marked/unmarked star // and use Icons_Manager::render_icon() — it loads Font Awesome (or SVG) properly on // the frontend. The simplest correct approach: use Font Awesome via the ICONS control. // // If you MUST use star_style = 'star_fontawesome' with inline <i> tags, use FA classes: // filled → 'fas fa-star' (fa-solid) // outline → 'far fa-star' (fa-regular) // NOT eicon-star / eicon-star-o. // // The cleanest production approach (used below) is to register explicit ICONS controls // for the filled and empty stars, then call Icons_Manager::render_icon() for each. // Example control additions to register_controls(): // $this->add_control( 'icon_marked', [ 'type' => Controls_Manager::ICONS, // 'default' => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ] ] ); // $this->add_control( 'icon_empty', [ 'type' => Controls_Manager::ICONS, // 'default' => [ 'value' => 'far fa-star', 'library' => 'fa-regular' ] ] ); $is_unicode = 'star_unicode' === $settings['star_style']; ?> <div class="myplugin-star-rating" role="img" aria-label="<?php echo esc_attr( $rating . '/' . $max ); ?>"> <?php if ( ! empty( $settings['title'] ) ) : ?> <span class="myplugin-star-rating__title"><?php echo esc_html( $settings['title'] ); ?></span> <?php endif; ?> <div class="myplugin-star-rating__wrapper"> <?php for ( $i = 1; $i <= $max; $i++ ) : $is_filled = ( $i <= $rating ); $class = $is_filled ? 'myplugin-star-full' : 'myplugin-star-empty'; ?> <span class="<?php echo esc_attr( $class ); ?>"> <?php if ( $is_unicode ) : // Unicode path — safe, no external font dependency. $is_solid_empty = 'solid' === ( $settings['unmarked_star_style'] ?? 'solid' ); $char = $is_filled ? '★' : ( $is_solid_empty ? '★' : '☆' ); echo esc_html( $char ); else : // Font Awesome path — use Icons_Manager::render_icon() for correct // frontend enqueue. Requires icon_marked / icon_empty ICONS controls. $icon_setting = $is_filled ? ( $settings['icon_marked'] ?? [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ] ) : ( $settings['icon_empty'] ?? [ 'value' => 'far fa-star', 'library' => 'fa-regular' ] ); \Elementor\Icons_Manager::render_icon( $icon_setting, [ 'aria-hidden' => 'true' ] ); endif; ?> </span> <?php endfor; ?> </div> </div> <?php } protected function content_template(): void { ?> <# var rating = parseFloat( settings.rating ) || 0; var max = parseInt( settings.rating_scale ) || 5; rating = Math.min( rating, max ); var isUnicode = 'star_unicode' === settings.star_style; var marked = 'solid' === settings.unmarked_star_style; var stars = ''; for ( var i = 1; i <= max; i++ ) { var isFilled = ( i <= rating ); var cls = isFilled ? 'myplugin-star-full' : 'myplugin-star-empty'; var icon; if ( isUnicode ) { icon = isFilled ? '★' : ( marked ? '★' : '☆' ); } else { // ✅ Use ICONS control values — renderIcon handles FA properly in the editor. // icon_marked / icon_empty are ICONS controls added in register_controls(). var iconSetting = isFilled ? ( settings.icon_marked || { value: 'fas fa-star', library: 'fa-solid' } ) : ( settings.icon_empty || { value: 'far fa-star', library: 'fa-regular' } ); var iconHTML = elementor.helpers.renderIcon( view, iconSetting, { 'aria-hidden': true }, 'i', 'object' ); icon = iconHTML && iconHTML.rendered ? iconHTML.value : '<i class="fas fa-star" aria-hidden="true"></i>'; } stars += '<span class="' + cls + '">' + icon + '</span>'; } #> <div class="myplugin-star-rating"> <# if ( settings.title ) { #> <span class="myplugin-star-rating__title">{{ settings.title }}</span> <# } #> <div class="myplugin-star-rating__wrapper">{{{ stars }}}</div> </div> <?php } ``` -
widget-tabs.md 10.2 KB
# Widget Boilerplate — Tabs > **When to use this file:** Load whenever building a widget that shows multiple content panels via clickable tabs. > Verified against `elementor/includes/widgets/tabs.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > ✅ Uses REPEATER control — each tab is one repeater item with title + content. --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_tabs', [ 'label' => esc_html__( 'Tabs', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $repeater = new \Elementor\Repeater(); $repeater->add_control( 'tab_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Tab Title', 'myplugin' ), 'placeholder' => esc_html__( 'Tab Title', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $repeater->add_control( 'tab_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'type' => \Elementor\Controls_Manager::WYSIWYG, 'default' => esc_html__( 'Tab Content', 'myplugin' ), 'show_label' => false, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'tabs', [ 'label' => esc_html__( 'Tabs Items', 'myplugin' ), 'type' => \Elementor\Controls_Manager::REPEATER, 'fields' => $repeater->get_controls(), 'default' => [ [ 'tab_title' => esc_html__( 'Tab #1', 'myplugin' ), 'tab_content' => esc_html__( 'Tab content goes here.', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Tab #2', 'myplugin' ), 'tab_content' => esc_html__( 'Tab content goes here.', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Tab #3', 'myplugin' ), 'tab_content' => esc_html__( 'Tab content goes here.', 'myplugin' ) ], ], 'title_field' => '{{{ tab_title }}}', ] ); $this->add_control( 'type', [ 'label' => esc_html__( 'Type', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'horizontal', 'options' => [ 'horizontal' => esc_html__( 'Horizontal', 'myplugin' ), 'vertical' => esc_html__( 'Vertical', 'myplugin' ), ], 'prefix_class' => 'elementor-tabs-view-', 'separator' => 'before', ] ); $this->add_control( 'tab_align_horizontal', [ 'label' => esc_html__( 'Tabs Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], 'justify' => [ 'title' => esc_html__( 'Justify', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ], ], 'prefix_class' => 'elementor-tabs-alignment-', 'condition' => [ 'type' => 'horizontal' ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_tabs_style', [ 'label' => esc_html__( 'Tabs', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'navigation_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-tab-title' => 'color: {{VALUE}};' ], ] ); $this->add_control( 'tab_active_color', [ 'label' => esc_html__( 'Active Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-tab-title.myplugin-active' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'tab_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], 'selector' => '{{WRAPPER}} .myplugin-tab-title', ] ); $this->end_controls_section(); $this->start_controls_section( 'section_content_style', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'content_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ], 'selectors' => [ '{{WRAPPER}} .myplugin-tab-content' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'content_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], 'selector' => '{{WRAPPER}} .myplugin-tab-content', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $tabs = $settings['tabs']; if ( empty( $tabs ) ) return; // ✅ CRITICAL: Include widget instance ID in all element IDs. // Using only $tab_count (1, 2, 3…) causes ID collisions when multiple Tab widgets // appear on the same page — aria-controls points to the WRONG panel in the second widget. // substr( $this->get_id(), 0, 3 ) gives a 3-char prefix unique to this widget instance. $id_int = substr( $this->get_id(), 0, 3 ); ?> <div class="myplugin-tabs"> <div class="myplugin-tabs-wrapper" role="tablist"> <?php foreach ( $tabs as $index => $tab ) : $tab_count = $index + 1; $is_active = 1 === $tab_count; ?> <div class="myplugin-tab-title<?php echo $is_active ? ' myplugin-active' : ''; ?>" id="myplugin-tab-title-<?php echo esc_attr( $id_int . $tab_count ); ?>" role="tab" aria-controls="myplugin-tab-content-<?php echo esc_attr( $id_int . $tab_count ); ?>" aria-selected="<?php echo $is_active ? 'true' : 'false'; ?>" tabindex="<?php echo $is_active ? '0' : '-1'; ?>"> <?php echo esc_html( $tab['tab_title'] ); ?> </div> <?php endforeach; ?> </div> <div class="myplugin-tabs-content-wrapper"> <?php foreach ( $tabs as $index => $tab ) : $tab_count = $index + 1; $is_active = 1 === $tab_count; ?> <div class="myplugin-tab-content<?php echo $is_active ? ' myplugin-active' : ''; ?>" id="myplugin-tab-content-<?php echo esc_attr( $id_int . $tab_count ); ?>" role="tabpanel" aria-labelledby="myplugin-tab-title-<?php echo esc_attr( $id_int . $tab_count ); ?>"> <?php echo wp_kses_post( $tab['tab_content'] ); ?> </div> <?php endforeach; ?> </div> </div> <?php } ``` > ⚠️ **Keyboard interaction requirement (ARIA APG §Tabs):** Elements with `role="tab"` are > not native controls, so the markup above is not keyboard-operable on its own. Your widget JS > **MUST** handle: `Enter`/`Space` to activate the focused tab, `ArrowLeft`/`ArrowRight` (or > Up/Down for vertical) to move focus between tabs, and roving `tabindex` (`0` on the active > tab, `-1` on the rest — already emitted by `render()`). Activate the panel on focus or on > Enter/Space, and keep `aria-selected` + `tabindex` in sync. > Source: w3.org/WAI/ARIA/apg/patterns/tabs/ > **Remove checklist:** > - `type` + `tab_align_horizontal` → remove if tabs are always horizontal **content_template() skeleton:** ```php protected function content_template(): void { ?> <# if ( ! settings.tabs || ! settings.tabs.length ) { return; } #> <div class="myplugin-tabs"> <div class="myplugin-tabs-wrapper" role="tablist"> <# _.each( settings.tabs, function( tab, index ) { var isActive = 0 === index; #> <div class="myplugin-tab-title{{ isActive ? ' myplugin-active' : '' }}" role="tab" aria-selected="{{ isActive ? 'true' : 'false' }}" tabindex="{{ isActive ? '0' : '-1' }}"> {{ tab.tab_title }} </div> <# } ); #> </div> <div class="myplugin-tabs-content-wrapper"> <# _.each( settings.tabs, function( tab, index ) { var isActive = 0 === index; #> <div class="myplugin-tab-content{{ isActive ? ' myplugin-active' : '' }}" role="tabpanel"> {{{ tab.tab_content }}} </div> <# } ); #> </div> </div> <?php } ``` -
widget-testimonial.md 11.8 KB
# Widget Boilerplate — Testimonial > **When to use this file:** Load whenever building a widget displaying a customer quote with name, job title, and image. > Verified against `elementor/includes/widgets/testimonial.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_testimonial', [ 'label' => esc_html__( 'Testimonial', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'testimonial_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXTAREA, 'rows' => 10, 'default' => esc_html__( 'Click edit button to change this text. Lorem ipsum dolor sit amet consectetur adipiscing elit dolor', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'testimonial_image', [ 'label' => esc_html__( 'Choose Image', 'myplugin' ), 'type' => \Elementor\Controls_Manager::MEDIA, 'default' => [ 'url' => \Elementor\Utils::get_placeholder_image_src() ], 'dynamic' => [ 'active' => true ], ] ); // ✅ REQUIRED — render() calls get_attachment_image_html( $settings, 'thumbnail', 'testimonial_image' ) // which reads the 'testimonial_image_size' control generated here. $this->add_group_control( \Elementor\Group_Control_Image_Size::get_type(), [ 'name' => 'testimonial_image', // generates 'testimonial_image_size' + 'testimonial_image_custom_dimension' 'default' => 'thumbnail', 'separator' => 'none', ] ); $this->add_control( 'testimonial_name', [ 'label' => esc_html__( 'Name', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'John Doe', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'testimonial_job', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Designer', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], 'separator' => 'before', ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_style_testimonial_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'testimonial_alignment', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-testimonial' => 'text-align: {{VALUE}};' ], ] ); $this->add_control( 'content_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ], 'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-content' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'content_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], 'selector' => '{{WRAPPER}} .myplugin-testimonial-content', ] ); $this->end_controls_section(); $this->start_controls_section( 'section_style_testimonial_image', [ 'label' => esc_html__( 'Image', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'image_size', [ 'label' => esc_html__( 'Size', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 20, 'max' => 200 ] ], 'default' => [ 'size' => 60, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-image img' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};', ], ] ); $this->add_responsive_control( 'image_border_radius', [ 'label' => esc_html__( 'Border Radius', 'myplugin' ), 'type' => \Elementor\Controls_Manager::DIMENSIONS, 'size_units' => [ 'px', '%' ], 'default' => [ 'unit' => '%', 'top' => 50, 'right' => 50, 'bottom' => 50, 'left' => 50, 'isLinked' => true ], 'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-image img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};', ], ] ); $this->end_controls_section(); $this->start_controls_section( 'section_style_testimonial_name', [ 'label' => esc_html__( 'Name', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'name_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-name' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'name_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], 'selector' => '{{WRAPPER}} .myplugin-testimonial-name', ] ); $this->end_controls_section(); $this->start_controls_section( 'section_style_testimonial_job', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'job_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_SECONDARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-job' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'job_typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_SECONDARY ], 'selector' => '{{WRAPPER}} .myplugin-testimonial-job', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); ?> <div class="myplugin-testimonial"> <div class="myplugin-testimonial-content"> <?php echo wp_kses_post( $settings['testimonial_content'] ); ?> </div> <div class="myplugin-testimonial-meta"> <?php if ( ! empty( $settings['testimonial_image']['url'] ) ) : ?> <div class="myplugin-testimonial-image"> <?php echo wp_kses_post( \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings, 'thumbnail', 'testimonial_image' ) ); ?> </div> <?php endif; ?> <div class="myplugin-testimonial-details"> <?php if ( ! empty( $settings['testimonial_name'] ) ) : // ✅ Wire up the Link control — wrap the name in an <a> when a URL is set. $name = esc_html( $settings['testimonial_name'] ); if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'name_link', $settings['link'] ); $name = '<a ' . $this->get_render_attribute_string( 'name_link' ) . '>' . $name . '</a>'; } ?> <div class="myplugin-testimonial-name"><?php echo wp_kses_post( $name ); ?></div> <?php endif; ?> <?php if ( ! empty( $settings['testimonial_job'] ) ) : ?> <div class="myplugin-testimonial-job"><?php echo esc_html( $settings['testimonial_job'] ); ?></div> <?php endif; ?> </div> </div> </div> <?php } ``` > **Remove checklist:** > - `testimonial_image` → remove if no portrait/avatar needed > - `link` → remove if testimonial is not linked > - `testimonial_job` → remove if job title is not required **content_template() skeleton:** ```php protected function content_template(): void { ?> <# // ✅ view.model replaces deprecated view.getEditModel() (removed in Elementor 3.x). // elementor.imagesManager.getImageUrl() reads image size from the model to pick srcset. var image = { id: settings.testimonial_image.id, url: settings.testimonial_image.url, size: settings.testimonial_image_size, dimension: settings.testimonial_image_custom_dimension, model: view.model, }; var image_url = elementor.imagesManager.getImageUrl( image ); #> <div class="myplugin-testimonial"> <div class="myplugin-testimonial-content">{{ settings.testimonial_content }}</div> <div class="myplugin-testimonial-meta"> <# if ( image_url ) { #> <div class="myplugin-testimonial-image"><img src="{{ image_url }}" alt=""></div> <# } #> <div class="myplugin-testimonial-details"> <# if ( settings.testimonial_name ) { // ✅ Escape the name before it reaches {{{ nameHtml }}} (triple-brace needed // for the optional <a>) — see field-notes.md §2. var safeName = _.escape( settings.testimonial_name ); var nameHtml = settings.link && settings.link.url ? '<a href="' + _.escape( settings.link.url ) + '">' + safeName + '</a>' : safeName; #> <div class="myplugin-testimonial-name">{{{ nameHtml }}}</div> <# } #> <# if ( settings.testimonial_job ) { #> <div class="myplugin-testimonial-job">{{ settings.testimonial_job }}</div> <# } #> </div> </div> </div> <?php } ``` -
widget-text-editor.md 4.6 KB
# Widget Boilerplate — Text Editor > **When to use this file:** Load whenever building a widget with a rich text / WYSIWYG body content area. > Verified against `elementor/includes/widgets/text-editor.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_editor', [ 'label' => esc_html__( 'Text Editor', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'editor', [ 'label' => '', 'type' => \Elementor\Controls_Manager::WYSIWYG, 'default' => esc_html__( 'Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_style', [ 'label' => esc_html__( 'Text Editor', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_responsive_control( 'align', [ 'label' => esc_html__( 'Alignment', 'myplugin' ), 'type' => \Elementor\Controls_Manager::CHOOSE, 'options' => [ 'left' => [ 'title' => esc_html__( 'Left', 'myplugin' ), 'icon' => 'eicon-text-align-left' ], 'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ], 'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-text-align-right' ], 'justify' => [ 'title' => esc_html__( 'Justify', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ], ], 'selectors' => [ '{{WRAPPER}} .myplugin-text-editor' => 'text-align: {{VALUE}};' ], ] ); $this->add_control( 'text_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} .myplugin-text-editor' => 'color: {{VALUE}};', '{{WRAPPER}} .myplugin-text-editor p' => 'color: {{VALUE}};', ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'typography', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], 'selector' => '{{WRAPPER}} .myplugin-text-editor', ] ); $this->add_group_control( \Elementor\Group_Control_Text_Shadow::get_type(), [ 'name' => 'text_shadow', 'selector' => '{{WRAPPER}} .myplugin-text-editor', ] ); $this->end_controls_section(); } ``` **render() skeleton:** ```php // ✅ REQUIRED on every widget — removes the redundant inner wrapper div. // Return true ONLY if your render() output physically requires the inner wrapper. // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/ public function has_widget_inner_wrapper(): bool { return false; } // ✅ Return false to enable output caching for static widgets (same HTML for all users). // Return true if output varies per user, session, time, or random values. // Source: developers.elementor.com/docs/widgets/widget-output-caching/ protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $this->add_render_attribute( 'editor', 'class', [ 'myplugin-text-editor', 'elementor-clearfix' ] ); // ✅ wp_kses_post() — WYSIWYG output must allow HTML tags but still be sanitized. // Official Elementor docs omit wp_kses_post() in examples, but our skill's php-standards.md // mandates escaping all output. WYSIWYG content is trusted editor HTML, so wp_kses_post is correct. echo '<div ' . $this->get_render_attribute_string( 'editor' ) . '>' . wp_kses_post( $settings['editor'] ) . '</div>'; } protected function content_template(): void { ?> <div class="myplugin-text-editor elementor-clearfix">{{{ settings.editor }}}</div> <?php } ``` > **Remove checklist:** > - `text_shadow` → remove for flat designs -
widget-text-path.md 13.1 KB
# Widget Boilerplate — Text Path > **When to use this file:** Load whenever building a widget that renders text following a > curved or custom SVG path (wave, arc, circle, spiral, or a user-supplied SVG). > Mirrors Elementor's native **Text Path** widget (free, since ~3.3). Built on V3 `Widget_Base` > (Elementor 3.x–4.x; current through 4.2). > > **How it works:** an inline `<svg>` contains a `<path>` plus a `<text><textPath href="#id">` > that flows the text along that path. The path needs a **unique id per widget instance** > (derived from `get_id()`) so multiple Text Path widgets on one page don't collide. --- ## ⚠️ Two ways to supply the path — security note | Source | Safe? | Notes | |---|---|---| | **Preset shapes** (hardcoded `d` attributes below) | ✅ Yes | Recommended default — the geometry is developer-defined, never user input. | | **Custom SVG upload** (`MEDIA`, `svg`) | ⚠️ Conditional | SVG is an XSS vector. You MUST sanitize uploaded SVG before inlining it. Elementor ships an SVG sanitizer (`\Elementor\Core\Files\File_Types\Svg`) and only allows SVG uploads when the **"SVG Uploads"** option is enabled. Never `echo` raw uploaded SVG markup. The simplest safe approach is to read only the first `<path d="...">` value and re-emit it through `esc_attr()`. | This boilerplate uses **presets** as the safe default and shows the custom path as an opt-in. --- ## register_controls() skeleton ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_text_path', [ 'label' => esc_html__( 'Text Path', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'text', [ 'label' => esc_html__( 'Text', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXTAREA, 'default' => esc_html__( 'Add Your Curved Text Here', 'myplugin' ), 'placeholder' => esc_html__( 'Enter your text', 'myplugin' ), 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'path_type', [ 'label' => esc_html__( 'Path', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'wave', 'options' => [ 'wave' => esc_html__( 'Wave', 'myplugin' ), 'arc' => esc_html__( 'Arc', 'myplugin' ), 'line' => esc_html__( 'Line', 'myplugin' ), 'circle' => esc_html__( 'Circle', 'myplugin' ), 'custom' => esc_html__( 'Custom SVG', 'myplugin' ), ], ] ); // Custom SVG — only when path_type = custom (see security note above). $this->add_control( 'custom_svg', [ 'label' => esc_html__( 'Custom SVG', 'myplugin' ), 'type' => \Elementor\Controls_Manager::MEDIA, 'media_types' => [ 'svg' ], 'condition' => [ 'path_type' => 'custom' ], 'description' => esc_html__( 'Requires SVG uploads enabled and a sanitized SVG. The first <path> is used.', 'myplugin' ), ] ); $this->add_control( 'link', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::URL, 'dynamic' => [ 'active' => true ], ] ); // ✅ startOffset along the path (0–100%). Set as an SVG attribute in render(), NOT CSS. $this->add_control( 'start_point', [ 'label' => esc_html__( 'Starting Point', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'default' => [ 'unit' => '%', 'size' => 0 ], 'size_units' => [ '%' ], 'range' => [ '%' => [ 'min' => 0, 'max' => 100 ] ], ] ); $this->add_control( 'show_path', [ 'label' => esc_html__( 'Show Path', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', ] ); $this->end_controls_section(); // ── STYLE: Text ─────────────────────────────────────────── $this->start_controls_section( 'section_text_style', [ 'label' => esc_html__( 'Text', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); // ✅ Typography group control — font-family/size/weight/spacing all apply to SVG <text>. $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'text_typography', 'selector' => '{{WRAPPER}} .myplugin-text-path text', ] ); // ✅ SVG text color is the `fill` property — NOT `color`. $this->add_control( 'text_color', [ 'label' => esc_html__( 'Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '', 'selectors' => [ '{{WRAPPER}} .myplugin-text-path text' => 'fill: {{VALUE}};' ], ] ); $this->add_control( 'text_hover_color', [ 'label' => esc_html__( 'Hover Text Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-text-path:hover text' => 'fill: {{VALUE}};' ], ] ); $this->end_controls_section(); // ── STYLE: Path ─────────────────────────────────────────── $this->start_controls_section( 'section_path_style', [ 'label' => esc_html__( 'Path', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, 'condition' => [ 'show_path' => 'yes' ], ] ); $this->add_control( 'path_color', [ 'label' => esc_html__( 'Path Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'default' => '#d0d0d0', 'selectors' => [ '{{WRAPPER}} .myplugin-text-path path' => 'stroke: {{VALUE}};' ], ] ); $this->add_control( 'path_width', [ 'label' => esc_html__( 'Path Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 20 ] ], 'default' => [ 'size' => 1, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-text-path path' => 'stroke-width: {{SIZE}}{{UNIT}};' ], ] ); $this->end_controls_section(); } ``` --- ## Preset path helper ```php /** * Developer-defined SVG path presets. Each preset is a [ 'd', 'viewBox' ] pair. * Because these `d` strings are hardcoded (never user input), they are safe to output. * * @return array<string, array{d:string, viewBox:string}> */ private function get_path_presets(): array { return [ 'wave' => [ 'd' => 'M0,50 Q100,0 200,50 T400,50', 'viewBox' => '0 0 400 100' ], 'arc' => [ 'd' => 'M10,90 A190,190 0 0 1 390,90', 'viewBox' => '0 0 400 110' ], 'line' => [ 'd' => 'M0,50 L400,50', 'viewBox' => '0 0 400 100' ], 'circle' => [ 'd' => 'M200,40 a160,160 0 1,1 -0.1,0', 'viewBox' => '0 0 400 400' ], ]; } ``` --- ## has_widget_inner_wrapper() + is_dynamic_content() + render() skeleton ```php public function has_widget_inner_wrapper(): bool { return false; } // ✅ Static unless the text comes from a per-request Dynamic Tag — false is the safe default. protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $text = $settings['text'] ?? ''; if ( '' === $text ) { return; } // ✅ Unique path id per instance — prevents <textPath href="#id"> collisions when // multiple Text Path widgets share a page. get_id() is the Elementor element id. $path_id = 'myplugin-text-path-' . $this->get_id(); $presets = $this->get_path_presets(); $type = $settings['path_type'] ?? 'wave'; if ( 'custom' === $type && ! empty( $settings['custom_svg']['url'] ) ) { // ⚠️ Custom SVG path. Resolve the uploaded file, sanitize, and extract ONLY the // first `d` attribute — never inline the whole uploaded SVG (XSS risk). $d = $this->get_custom_path_d( (int) ( $settings['custom_svg']['id'] ?? 0 ) ); $view_box = '0 0 400 100'; // fall back; adjust to your SVGs or read it the same way if ( '' === $d ) { return; } } else { $preset = $presets[ $type ] ?? $presets['wave']; $d = $preset['d']; $view_box = $preset['viewBox']; } // startOffset is an SVG attribute (percentage), not CSS. $start_offset = (int) ( $settings['start_point']['size'] ?? 0 ); // Path stroke visibility: 'none' hides it, controls re-enable it via the Path style section. $path_fill = 'none'; $path_stroke = 'yes' === ( $settings['show_path'] ?? '' ) ? 'currentColor' : 'transparent'; $this->add_render_attribute( 'wrapper', 'class', 'myplugin-text-path' ); $open_link = ''; $close_link = ''; if ( ! empty( $settings['link']['url'] ) ) { $this->add_link_attributes( 'link', $settings['link'] ); $open_link = '<a ' . $this->get_render_attribute_string( 'link' ) . '>'; $close_link = '</a>'; } ?> <div <?php $this->print_render_attribute_string( 'wrapper' ); ?>> <?php echo $open_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built via add_link_attributes(), pre-sanitized ?> <svg viewBox="<?php echo esc_attr( $view_box ); ?>" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg"> <path id="<?php echo esc_attr( $path_id ); ?>" d="<?php echo esc_attr( $d ); ?>" fill="<?php echo esc_attr( $path_fill ); ?>" stroke="<?php echo esc_attr( $path_stroke ); ?>"></path> <text> <textPath href="#<?php echo esc_attr( $path_id ); ?>" xlink:href="#<?php echo esc_attr( $path_id ); ?>" startOffset="<?php echo esc_attr( $start_offset ); ?>%"> <?php echo esc_html( $text ); ?> </textPath> </text> </svg> <?php echo $close_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- closing tag literal ?> </div> <?php } /** * Read the first `d` attribute from a sanitized uploaded SVG. * Returns '' if the attachment is missing, not an SVG, or contains no <path>. */ private function get_custom_path_d( int $attachment_id ): string { if ( $attachment_id <= 0 ) { return ''; } $file = get_attached_file( $attachment_id ); if ( ! $file || ! is_readable( $file ) || 'image/svg+xml' !== get_post_mime_type( $attachment_id ) ) { return ''; } $svg = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( ! $svg ) { return ''; } // ✅ Extract ONLY the first path `d` value — never inline the full SVG. if ( preg_match( '/<path[^>]*\sd="([^"]+)"/i', $svg, $m ) ) { return $m[1]; // esc_attr() is applied at output in render() } return ''; } ``` --- ## content_template() skeleton ```php protected function content_template(): void { ?> <# if ( ! settings.text ) { return; } // Preset map mirrored from get_path_presets() — keep both in sync. var presets = { wave: { d: 'M0,50 Q100,0 200,50 T400,50', viewBox: '0 0 400 100' }, arc: { d: 'M10,90 A190,190 0 0 1 390,90', viewBox: '0 0 400 110' }, line: { d: 'M0,50 L400,50', viewBox: '0 0 400 100' }, circle: { d: 'M200,40 a160,160 0 1,1 -0.1,0', viewBox: '0 0 400 400' } }; var preset = presets[ settings.path_type ] || presets.wave; var pathId = 'myplugin-text-path-preview'; var offset = settings.start_point && settings.start_point.size ? settings.start_point.size : 0; var showPath = 'yes' === settings.show_path; #> <div class="myplugin-text-path"> <svg viewBox="{{ preset.viewBox }}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg"> <path id="{{ pathId }}" d="{{ preset.d }}" fill="none" stroke="{{ showPath ? 'currentColor' : 'transparent' }}"></path> <text><textPath href="#{{ pathId }}" startOffset="{{ offset }}%">{{ settings.text }}</textPath></text> </svg> </div> <?php } ``` > **Note on the editor preview:** `content_template()` cannot read a custom uploaded SVG's path > (that resolution is PHP-side), so the preview always uses a preset shape — the custom path > renders on the frontend only. This matches how Elementor's own SVG-dependent widgets behave. --- > **Remove checklist:** > - `custom_svg` + `get_custom_path_d()` → remove if only preset paths are needed (drops the SVG security surface entirely). > - `link` → remove if the curved text is never a link. > - Path style section + `show_path` → remove if the path line is always hidden. > - `text_hover_color` → remove if there is no hover state. -
widget-toggle.md 12.5 KB
# Widget Boilerplate — Toggle > **When to use this file:** Load whenever building a collapsible content widget where multiple panels can be open simultaneously. > Verified against `elementor/includes/widgets/toggle.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). > ⚠️ Toggle is structurally identical to Accordion — the only differences are noted below. --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_toggle', [ 'label' => esc_html__( 'Toggle', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $repeater = new \Elementor\Repeater(); $repeater->add_control( 'tab_title', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => esc_html__( 'Toggle Title', 'myplugin' ), 'label_block' => true, 'dynamic' => [ 'active' => true ], ] ); $repeater->add_control( 'tab_content', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'type' => \Elementor\Controls_Manager::WYSIWYG, 'default' => esc_html__( 'Toggle Content', 'myplugin' ), 'show_label' => false, 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'tabs', [ 'label' => esc_html__( 'Toggle Items', 'myplugin' ), 'type' => \Elementor\Controls_Manager::REPEATER, 'fields' => $repeater->get_controls(), 'default' => [ [ 'tab_title' => esc_html__( 'Toggle #1', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Toggle #2', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ], [ 'tab_title' => esc_html__( 'Toggle #3', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ], ], 'title_field' => '{{{ tab_title }}}', ] ); $this->add_control( 'selected_icon', [ 'label' => esc_html__( 'Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'separator' => 'before', 'fa4compatibility' => 'icon', 'default' => [ 'value' => 'fas fa-plus', 'library' => 'fa-solid' ], 'label_block' => false, 'skin' => 'inline', ] ); $this->add_control( 'selected_active_icon', [ 'label' => esc_html__( 'Active Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::ICONS, 'fa4compatibility' => 'icon_active', 'default' => [ 'value' => 'fas fa-minus', 'library' => 'fa-solid' ], 'label_block' => false, 'skin' => 'inline', 'condition' => [ 'selected_icon[value]!' => '' ], ] ); $this->add_control( 'title_html_tag', [ 'label' => esc_html__( 'Title HTML Tag', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ 'p' => 'p', 'div' => 'div', 'span' => 'span', 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6' ], 'default' => 'p', 'separator' => 'before', ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_toggle_style', [ 'label' => esc_html__( 'Toggle', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'border_width', [ 'label' => esc_html__( 'Border Width', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SLIDER, 'range' => [ 'px' => [ 'min' => 0, 'max' => 10 ] ], 'default' => [ 'size' => 1, 'unit' => 'px' ], 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-item, {{WRAPPER}} .myplugin-toggle-title, {{WRAPPER}} .myplugin-toggle-content' => 'border-width: {{SIZE}}{{UNIT}};', ], ] ); $this->add_control( 'border_color', [ 'label' => esc_html__( 'Border Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-item, {{WRAPPER}} .myplugin-toggle-title, {{WRAPPER}} .myplugin-toggle-content' => 'border-color: {{VALUE}};', ], ] ); $this->end_controls_section(); $this->start_controls_section( 'section_title_style', [ 'label' => esc_html__( 'Title', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'title_background', [ 'label' => esc_html__( 'Background', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-title' => 'background-color: {{VALUE}};' ], ] ); $this->add_control( 'title_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ], 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-title' => 'color: {{VALUE}};' ], ] ); $this->add_control( 'title_active_color', [ 'label' => esc_html__( 'Active Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_ACCENT ], 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-title.myplugin-active' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'title_typography', 'selector' => '{{WRAPPER}} .myplugin-toggle-title', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ], ] ); $this->end_controls_section(); $this->start_controls_section( 'section_content_style', [ 'label' => esc_html__( 'Content', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'content_background_color', [ 'label' => esc_html__( 'Background', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-content' => 'background-color: {{VALUE}};' ], ] ); $this->add_control( 'content_color', [ 'label' => esc_html__( 'Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ], 'selectors' => [ '{{WRAPPER}} .myplugin-toggle-content' => 'color: {{VALUE}};' ], ] ); $this->add_group_control( \Elementor\Group_Control_Typography::get_type(), [ 'name' => 'content_typography', 'selector' => '{{WRAPPER}} .myplugin-toggle-content', 'global' => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ], ] ); $this->end_controls_section(); } ``` > **Key difference from Accordion:** > Toggle allows multiple panels open at the same time. > In JS, do NOT close other panels when one opens — each item manages its own open/closed state independently. > ⚠️ **Keyboard interaction requirement:** Elements with `role="button"` on non-native button > tags require explicit `keydown` handlers for `Enter` and `Space`. See `widget-accordion.md` > for the full keyboard handler pattern — it applies identically to toggle. > **Remove checklist:** > - `selected_icon` + `selected_active_icon` → remove if no expand/collapse icons needed > - `title_html_tag` → remove if title tag is always fixed **render() + content_template() skeleton:** ```php public function has_widget_inner_wrapper(): bool { return false; } protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $tabs = $settings['tabs']; if ( empty( $tabs ) ) { return; } // ✅ get_id() replaces deprecated get_id_int() (deprecated since Elementor 3.1.0) // Source: developers.elementor.com/v3-5-planned-deprecations/ $id_int = substr( $this->get_id(), 0, 3 ); ?> <div class="myplugin-toggle"> <?php foreach ( $tabs as $index => $tab ) : $tab_count = $index + 1; $tab_id = 'myplugin-toggle-' . $id_int . $tab_count; $tag = \Elementor\Utils::validate_html_tag( $settings['title_html_tag'] ); ?> <?php $content_id = 'myplugin-toggle-content-' . $id_int . $tab_count; ?> <div class="myplugin-toggle-item elementor-repeater-item-<?php echo esc_attr( $tab['_id'] ); ?>"> <<?php echo esc_attr( $tag ); ?> id="<?php echo esc_attr( $tab_id ); ?>" class="myplugin-toggle-title" aria-expanded="false" aria-controls="<?php echo esc_attr( $content_id ); ?>" role="button" tabindex="0"> <?php // ✅ BUG FIX: selected_icon / selected_active_icon are WIDGET-LEVEL controls // (added via $this->add_control), not per-item repeater controls. Check // $settings, not $tab — $tab['selected_icon'] is always empty, which would // make the icon never render. (Same fix as widget-accordion.md.) if ( ! empty( $settings['selected_icon']['value'] ) ) : ?> <span class="myplugin-toggle-icon"> <span class="myplugin-toggle-icon-opened"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_active_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> <span class="myplugin-toggle-icon-closed"> <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?> </span> </span> <?php endif; ?> <span class="myplugin-toggle-title-text"><?php echo esc_html( $tab['tab_title'] ); ?></span> </<?php echo esc_attr( $tag ); ?>> <div class="myplugin-toggle-content" id="<?php echo esc_attr( $content_id ); ?>" role="region" aria-labelledby="<?php echo esc_attr( $tab_id ); ?>" style="display:none;"> <?php echo wp_kses_post( $tab['tab_content'] ); ?> </div> </div> <?php endforeach; ?> </div> <?php } protected function content_template(): void { ?> <# if ( ! settings.tabs || ! settings.tabs.length ) { return; } var tag = elementor.helpers.validateHTMLTag( settings.title_html_tag ); #> <div class="myplugin-toggle"> <# _.each( settings.tabs, function( tab, index ) { // ✅ BUG FIX: selected_icon is a WIDGET-LEVEL control — read settings.*, not tab.*. var iconHTML = settings.selected_icon && settings.selected_icon.value ? elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' ) : null; #> <# var contentId = 'myplugin-toggle-content-' + index; #> <div class="myplugin-toggle-item"> <{{{ tag }}} class="myplugin-toggle-title" aria-expanded="false" aria-controls="{{ contentId }}" role="button" tabindex="0"> <# if ( iconHTML ) { #> <span class="myplugin-toggle-icon"> <span class="myplugin-toggle-icon-opened">{{{ elementor.helpers.renderIcon( view, settings.selected_active_icon, { 'aria-hidden': true }, 'i', 'object' ).value }}}</span> <span class="myplugin-toggle-icon-closed">{{{ iconHTML.value }}}</span> </span> <# } #> <span class="myplugin-toggle-title-text">{{ tab.tab_title }}</span> </{{{ tag }}}> <div class="myplugin-toggle-content" style="display:none;"> {{{ tab.tab_content }}} </div> </div> <# } ); #> </div> <?php } ``` -
widget-video.md 10.4 KB
# Widget Boilerplate — Video > **When to use this file:** Load whenever building a widget that embeds a video (YouTube, Vimeo, self-hosted). > Verified against `elementor/includes/widgets/video.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2). --- ```php protected function register_controls(): void { // ── CONTENT ─────────────────────────────────────────────── $this->start_controls_section( 'section_video', [ 'label' => esc_html__( 'Video', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, ] ); $this->add_control( 'video_type', [ 'label' => esc_html__( 'Source', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'youtube', 'options' => [ 'youtube' => esc_html__( 'YouTube', 'myplugin' ), 'vimeo' => esc_html__( 'Vimeo', 'myplugin' ), 'dailymotion' => esc_html__( 'Dailymotion', 'myplugin' ), 'hosted' => esc_html__( 'Self Hosted', 'myplugin' ), ], ] ); $this->add_control( 'youtube_url', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => 'https://www.youtube.com/watch?v=XHOmBV4js_E', 'placeholder' => esc_html__( 'Enter your YouTube URL', 'myplugin' ), 'label_block' => true, 'condition' => [ 'video_type' => 'youtube' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'vimeo_url', [ 'label' => esc_html__( 'Link', 'myplugin' ), 'type' => \Elementor\Controls_Manager::TEXT, 'default' => 'https://vimeo.com/235215203', 'placeholder' => esc_html__( 'Enter your Vimeo URL', 'myplugin' ), 'label_block' => true, 'condition' => [ 'video_type' => 'vimeo' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'hosted_url', [ 'label' => esc_html__( 'Choose File', 'myplugin' ), 'type' => \Elementor\Controls_Manager::MEDIA, 'media_type' => 'video', 'condition' => [ 'video_type' => 'hosted' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'start', [ 'label' => esc_html__( 'Start Time', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'description' => esc_html__( 'Specify a start time (in seconds)', 'myplugin' ), 'condition' => [ 'video_type' => [ 'youtube', 'hosted' ] ], ] ); $this->add_control( 'end', [ 'label' => esc_html__( 'End Time', 'myplugin' ), 'type' => \Elementor\Controls_Manager::NUMBER, 'description' => esc_html__( 'Specify an end time (in seconds)', 'myplugin' ), 'condition' => [ 'video_type' => [ 'youtube', 'hosted' ] ], ] ); $this->add_control( 'video_options', [ 'label' => esc_html__( 'Video Options', 'myplugin' ), 'type' => \Elementor\Controls_Manager::HEADING, 'separator' => 'before', ] ); $this->add_control( 'autoplay', [ 'label' => esc_html__( 'Autoplay', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', ] ); $this->add_control( 'mute', [ 'label' => esc_html__( 'Mute', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', ] ); $this->add_control( 'loop', [ 'label' => esc_html__( 'Loop', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', ] ); $this->add_control( 'controls', [ 'label' => esc_html__( 'Player Controls', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ] ); $this->add_control( 'show_image_overlay', [ 'label' => esc_html__( 'Privacy Mode', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', 'separator' => 'before', ] ); $this->add_control( 'image_overlay', [ 'label' => esc_html__( 'Choose Image', 'myplugin' ), 'type' => \Elementor\Controls_Manager::MEDIA, 'default' => [ 'url' => \Elementor\Utils::get_placeholder_image_src() ], 'condition' => [ 'show_image_overlay' => 'yes' ], 'dynamic' => [ 'active' => true ], ] ); $this->add_control( 'show_play_icon', [ 'label' => esc_html__( 'Play Icon', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', 'condition' => [ 'show_image_overlay' => 'yes' ], ] ); $this->add_control( 'lightbox', [ 'label' => esc_html__( 'Lightbox', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => '', 'condition' => [ 'show_image_overlay' => 'yes' ], ] ); $this->end_controls_section(); // ── STYLE ───────────────────────────────────────────────── $this->start_controls_section( 'section_video_style', [ 'label' => esc_html__( 'Video', 'myplugin' ), 'tab' => \Elementor\Controls_Manager::TAB_STYLE, ] ); $this->add_control( 'aspect_ratio', [ 'label' => esc_html__( 'Aspect Ratio', 'myplugin' ), 'type' => \Elementor\Controls_Manager::SELECT, 'options' => [ '169' => '16:9', '219' => '21:9', '43' => '4:3', '32' => '3:2', '11' => '1:1', '916' => '9:16', ], 'default' => '169', // ✅ prefix_class ONLY — writes e.g. 'elementor-aspect-ratio-169' on the wrapper. // Elementor's own CSS uses this class with padding-top trick or aspect-ratio property. // DO NOT add a 'selectors' entry here — '{{VALUE}}' would output '169' which is invalid CSS. 'prefix_class' => 'elementor-aspect-ratio-', ] ); $this->add_group_control( \Elementor\Group_Control_Css_Filter::get_type(), [ 'name' => 'css_filters', 'selector' => '{{WRAPPER}} .elementor-wrapper', ] ); $this->add_control( 'play_icon_color', [ 'label' => esc_html__( 'Play Icon Color', 'myplugin' ), 'type' => \Elementor\Controls_Manager::COLOR, 'selectors' => [ '{{WRAPPER}} .elementor-custom-embed-play i' => 'color: {{VALUE}};' ], 'condition' => [ 'show_image_overlay' => 'yes', 'show_play_icon' => 'yes' ], ] ); $this->end_controls_section(); } ``` > **Remove checklist:** > - `start` + `end` → remove if no time-scrubbing needed > - `show_image_overlay` + `image_overlay` + `show_play_icon` + `lightbox` → remove if no overlay/privacy mode > - `css_filters` → remove for simple embeds **render() + content_template() skeleton:** ```php public function has_widget_inner_wrapper(): bool { return false; } // ✅ Video embeds change based on URL — not user-specific, so static caching is safe. protected function is_dynamic_content(): bool { return false; } protected function render(): void { $settings = $this->get_settings_for_display(); $video_url = ''; switch ( $settings['video_type'] ) { case 'youtube': $video_url = $settings['youtube_url']; break; case 'vimeo': $video_url = $settings['vimeo_url']; break; case 'hosted': $video_url = ! empty( $settings['hosted_url']['url'] ) ? $settings['hosted_url']['url'] : ''; break; } if ( empty( $video_url ) ) { return; } // ✅ Use wp_oembed_get() for YouTube/Vimeo — handles oEmbed parameters correctly. // For self-hosted, output an HTML5 <video> tag directly. if ( 'hosted' === $settings['video_type'] ) { $has_autoplay = 'yes' === $settings['autoplay']; $has_muted = 'yes' === $settings['mute']; // ✅ Browser Autoplay Policy: autoplay without muted is blocked in Chrome/Safari/Firefox. // Enforce muted when autoplay is requested to guarantee playback actually starts. // Do NOT silently strip autoplay — show it as requested but force muted alongside it. if ( $has_autoplay && ! $has_muted ) { $has_muted = true; // force muted so autoplay is honoured } // ✅ Build attributes array then implode — avoids raw HTML injection via esc_attr // on space-joined strings. Each boolean attribute is a plain ASCII word, safe to output. $attrs = array_filter( [ 'yes' === $settings['controls'] ? 'controls' : '', $has_autoplay ? 'autoplay' : '', $has_muted ? 'muted' : '', 'yes' === $settings['loop'] ? 'loop' : '', 'playsinline', // always — prevents iOS Safari fullscreen takeover ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $attrs are all hardcoded ASCII attribute name strings; $video_url escaped via esc_url below echo sprintf( '<div class="myplugin-video elementor-wrapper"><video %s src="%s"></video></div>', implode( ' ', $attrs ), esc_url( $video_url ) ); } else { $oembed = wp_oembed_get( $video_url ); if ( $oembed ) { // ✅ wp_kses_post() allows <iframe> (needed for YouTube/Vimeo oEmbed output). // Do NOT use esc_html() here — that would entity-encode the entire iframe tag. echo '<div class="myplugin-video elementor-wrapper">' . wp_kses_post( $oembed ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- kses-filtered above } } } protected function content_template(): void { ?> <# var url = ''; if ( 'youtube' === settings.video_type ) { url = settings.youtube_url; } else if ( 'vimeo' === settings.video_type ) { url = settings.vimeo_url; } else if ( 'hosted' === settings.video_type && settings.hosted_url ) { url = settings.hosted_url.url; } if ( ! url ) { return; } #> <div class="myplugin-video elementor-wrapper"> <div class="myplugin-video-placeholder"> <i class="eicon-video-camera" aria-hidden="true"></i> </div> </div> <?php } ``` -
woocommerce.md 8.5 KB
# WooCommerce Integration ## ⚠️ HPOS Compatibility — Required for All WooCommerce Plugins (WC 8.2+) WooCommerce High-Performance Order Storage (HPOS) uses dedicated order tables instead of `wp_posts`/`wp_postmeta`. It is **enabled by default for all new stores since WC 8.2 (October 2023)**. Any plugin that declares `WC tested up to` in its header and touches orders MUST declare HPOS compatibility or WooCommerce will block the feature with admin warnings. > ⚠️ **WooCommerce 10.7 (released April 14, 2026) — HPOS "Sync on Read" disabled by default:** > As of WooCommerce 10.7, the HPOS **"sync on read"** mechanism is disabled by default > (current stable is **11.0.1**, released Aug 10, 2026). > **What this means:** "Sync on read" was a safety net that detected when order data was written > directly to the legacy `wp_posts`/`wp_postmeta` tables (bypassing WooCommerce's API) and > pulled those changes back into HPOS on the next read. With this disabled, any plugin or custom > code that writes order data via `wp_update_post()`, `update_post_meta()`, or direct SQL will > **silently produce stale data** in HPOS. > > **Action required:** Audit all code that touches order data. Migrate to HPOS-native API. > ```php > // Reproduce the 10.7+ default (sync-on-read disabled) on an older WooCommerce to test: > add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_false' ); > > // Temporarily re-enable sync-on-read on 10.7+ (bridge only, while you migrate): > add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' ); > ``` > Re-enabling should be treated as a temporary measure only — sync-on-read may be fully > removed in a future WooCommerce version. > Filter source: `woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php` — > `apply_filters( 'woocommerce_hpos_enable_sync_on_read', ... )` (WooCommerce GitHub, `trunk` branch). > Feature announcement: developer.woocommerce.com/2026/02/16/hpos-sync-on-read-to-be-disabled-by-default-in-woocommerce-10-7/ ```php // HPOS compatibility declaration — add to your main plugin file (see scaffolding.md) add_action( 'before_woocommerce_init', function() { if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); } } ); ``` > 📌 **WooCommerce 10.9 (released June 23, 2026) — developer-facing changes** (no HPOS or > minimum-requirement changes; the HPOS guidance above stands): > - **Store API defers draft-order creation.** A persisted draft order is no longer created > early in a fresh checkout session (GET/PATCH); it is created **close to place-order time** > (fewer orphaned `checkout-draft` rows). Any code that assumes a draft order row exists as > soon as a checkout session starts — meta writes, hooks keyed on the draft order, analytics > on draft rows — must handle the order appearing late. > - **Product editor beta enters its FINAL deprecation window** — ✅ **removed in WC 11.0, see > below.** Don't build against it; target the classic product screen. > - **Abilities/MCP:** rebuilt canonical domain abilities for product & order operations > (query/create/update products, manage orders) on the WordPress **Abilities API** — see > `SKILL.md` (WP 7.0 section) for the `wp_abilities_api_init` registration rules. > - **Experimental:** a dual code-API (PHP classes) + autogenerated GraphQL mirror. Experimental > — don't ship production code against it yet. > - **Transactional email logging in core** (WooCommerce → Status → Logs) — no separate > logging plugin needed when debugging mail. > Source: developer.woocommerce.com/2026/06/23/woocommerce-10-9/ > 🔴 **WooCommerce 11.0 (released Aug 4, 2026; current 11.0.1, Aug 10) — two breaking changes.** > HPOS behaviour and the Order API above are **unchanged**; these are the items that break code: > > **1. The product editor beta is GONE.** The removal is complete, not deprecated: the > `@woocommerce/product-editor` package, the block-based product screens in WooCommerce Admin, > the feature flag/toggle, the editor-specific routes and menu entries, **and their extension > points**. The **classic product editor is now the only product editing experience in core.** > - **Product data is untouched** — no migration, no data loss; existing products edit normally. > - **What breaks:** any extension that registered a block, field, or tab *through the product > editor's extension points*, or that linked to a block-editor product route. Those hooks no > longer exist — port the UI to the classic screen (`add_meta_box()`, the standard product data > tabs via `woocommerce_product_data_tabs` / `woocommerce_product_data_panels`). > - Don't feature-detect the flag any more; it is not coming back. > > **2. Action Scheduler 4.0.0 is bundled**, and its **uniqueness rule changed**: > - **`$unique` now includes the action's ARGUMENTS in the dedup check.** Previously only the > hook + group were compared. Two `as_schedule_single_action()` calls with the same hook and > group but *different args* used to block each other and now **both schedule**. If you relied > on the old behaviour as a "only one of these ever queued" guard, that guard is gone — expect > more actions, and dedupe on your own key if you need the old semantics. > - **Cleanup of old actions moved** from inline work on every queue batch to a **dedicated daily > job at 3 AM site time.** Tests or monitoring that assumed old rows disappear during normal > processing need adjusting (a custom queue cleaner can restore inline behaviour). > - Requires WP 6.8+. See `wordpress-apis.md` §5 for when to reach for Action Scheduler at all. > > **Also in 11.0 (experimental — don't ship against these yet):** abandoned-cart emails, > block-based email editing, and a new Settings UI. > Sources: developer.woocommerce.com/2026/08/04/woocommerce-11-0/ · > developer.woocommerce.com/2026/06/02/product-editor-beta-retiring/ · > developer.woocommerce.com/2026/06/17/changes-to-action-scheduler/ --- ## HPOS Order API Rules **Always use these — never `get_post()` / `update_post_meta()` for orders:** ```php // ✅ Retrieve an order $order = wc_get_order( $order_id ); // returns WC_Order, works with both legacy and HPOS // ✅ Read order meta $value = $order->get_meta( '_my_meta_key' ); // ✅ Write order meta $order->update_meta_data( '_my_meta_key', $value ); $order->save(); // ❌ NEVER use these for orders when HPOS is active: // $order = get_post( $order_id ); // returns WP_Post — wrong type, may be empty // update_post_meta( $order_id, ... ); // writes to postmeta, bypasses HPOS tables // get_post_meta( $order_id, '_key', true ); // reads from postmeta, stale if HPOS is authoritative ``` --- ## Preferred: Elementor Loop Grid (No Template Files Touched) ```php // Use elementor-patterns.md Loop Grid pattern — set post_type to 'product' // Apply tax_query for product_cat / product_tag as needed // This is always safer than overriding template files add_action( 'elementor/query/myplugin_products_query', function( \WP_Query $query ) { $query->set( 'post_type', 'product' ); $query->set( 'posts_per_page', 6 ); $query->set( 'no_found_rows', true ); } ); ``` --- ## Hook-Based Additions (Safer Than Template Override) ```php add_action( 'woocommerce_after_add_to_cart_button', 'myplugin_after_cart_button' ); function myplugin_after_cart_button(): void { // ✅ Use wc_get_product() — NOT global $product. // global $product works on standard single-product pages but returns null or the // wrong product inside Elementor popups, Quick View overlays, and AJAX-rendered // product templates. wc_get_product( get_the_ID() ) is reliable in all contexts. $product = wc_get_product( get_the_ID() ); if ( ! $product instanceof WC_Product ) return; echo '<p class="myplugin-product__note">' . esc_html__( 'Free shipping on this item.', 'myplugin' ) . '</p>'; } ``` --- ## Template Override (Last Resort — Always Document Why) ``` Copy from: /wp-content/plugins/woocommerce/templates/ Place at: /wp-content/themes/mytheme-child/woocommerce/ Maintain: After every WooCommerce update, check WooCommerce → Status → System Status for "Outdated template files" warnings. WooCommerce compares the version declared in each template's @version comment against the bundled version. Outdated template overrides cause silently broken checkout flows. ``` -
wordpress-apis.md 16.4 KB
# Common WordPress APIs — Options, Settings, Metadata, Capabilities, Cron, i18n > **When to read this file:** Building an **admin settings page**, persisting plugin data, > exposing meta to REST/Elementor, gating actions by capability, scheduling background work, or > making a plugin translatable. These are the WordPress "Common APIs" the other files don't cover > (REST → `rest-api.md`; transients/HTTP/sanitization → `php-standards.md`; CPT/AJAX → > `scaffolding.md`). For exact, current function signatures, the canonical lookup is the **Code > Reference**: developer.wordpress.org/reference/ — verify there before relying on memory. --- ## 1. Options API — storing plugin settings ```php $opts = get_option( 'myplugin_options', [] ); // ALWAYS pass a default // ✅ WP 6.6+: pass an explicit BOOLEAN $autoload — not the legacy 'yes'/'no' strings. update_option( 'myplugin_options', $opts, true ); // true → autoloaded on every page update_option( 'myplugin_big_cache', $blob, false ); // false → NOT autoloaded (large/rare) add_option( 'myplugin_flag', '1', '', false ); // 3rd arg ($deprecated) stays '' delete_option( 'myplugin_options' ); ``` - **Store one option as an array**, not a dozen scalar options — fewer `wp_options` rows and a single autoload entry. Sanitize the whole array on write (see the Settings API below). - **`autoload` is a real performance lever.** Autoloaded options load on **every** request via the `alloptions` cache. Set `false` for anything large or rarely read (caches, logs, per-item blobs); bloated autoload is a common cause of slow sites. (WP 6.6 changed the default to "auto" so core can decide; be explicit anyway.) See `performance.md`. - Site-wide on multisite: `get_site_option()` / `update_site_option()`. --- ## 2. Settings API — the review-safe admin settings page The canonical pattern. `settings_fields()` emits the nonce, the **`sanitize_callback` is the single trusted place to clean input**, and field renderers **escape on output** — together that satisfies Plugin Check and wp.org review. ```php // (a) Register the setting + sections + fields on admin_init. add_action( 'admin_init', function () { register_setting( 'myplugin_group', 'myplugin_options', [ 'type' => 'array', 'sanitize_callback' => 'myplugin_sanitize_options', // ← the only trusted cleaning point 'default' => [ 'api_key' => '', 'enabled' => false ], 'show_in_rest' => false, // true only with a defined schema ] ); add_settings_section( 'myplugin_main', esc_html__( 'Main Settings', 'myplugin' ), '__return_false', // optional intro callback 'myplugin' // page slug (matches do_settings_sections below) ); add_settings_field( 'myplugin_api_key', esc_html__( 'API Key', 'myplugin' ), 'myplugin_field_api_key', 'myplugin', 'myplugin_main' ); } ); // (b) Sanitize callback — clean EVERY field; never trust $input. function myplugin_sanitize_options( $input ): array { return [ 'api_key' => sanitize_text_field( $input['api_key'] ?? '' ), 'enabled' => ! empty( $input['enabled'] ), ]; } // (c) Field renderer — escape on output. function myplugin_field_api_key(): void { $opts = get_option( 'myplugin_options', [] ); printf( '<input type="text" name="myplugin_options[api_key]" value="%s" class="regular-text" autocomplete="off">', esc_attr( $opts['api_key'] ?? '' ) ); } // (d) The admin page — capability-gated, with the settings_fields() nonce. add_action( 'admin_menu', function () { add_options_page( esc_html__( 'My Plugin', 'myplugin' ), // <title> esc_html__( 'My Plugin', 'myplugin' ), // menu label 'manage_options', // capability 'myplugin', // menu slug 'myplugin_render_settings_page' ); } ); function myplugin_render_settings_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } ?> <div class="wrap"> <h1><?php echo esc_html( get_admin_page_title() ); ?></h1> <form action="options.php" method="post"> <?php settings_fields( 'myplugin_group' ); // nonce + option_page + _wp_http_referer do_settings_sections( 'myplugin' ); // renders the sections + fields submit_button(); ?> </form> </div> <?php } ``` > ⚠️ **The `sanitize_callback` runs on EVERY `update_option()` for that option — including your > own programmatic writes.** `register_setting()` installs a `sanitize_option_{$option}` filter > that fires inside `update_option()` regardless of who calls it (whenever the registering code > has loaded — any `is_admin()` request, including `admin-post.php`). Three rules from a real > "activation silently reverted" bug: > - The callback must be a **pure function of `$input`** — never "preserve the current stored > value" for a key, and beware side effects. A "preserving" sanitizer silently reverted the > plugin's own `update_option( …, true )` on every activation click. > - **Runtime state does not belong in a registered settings array.** Flags your code toggles > (an "enabled" state, counters, timestamps) live in their **own unregistered option**; inject > them at read time if you want a single accessor. > - **WP-CLI smoke tests miss this bug class**: under CLI, `is_admin()` is false → the settings > page never loads → `register_setting()` never ran → the filter is absent. To reproduce admin > conditions, `add_filter( 'sanitize_option_myplugin_options', 'myplugin_sanitize_options' )` > before testing option writes. > **Secrets in settings:** never re-render an API secret into `value="…"` — show an empty field > with a "leave blank to keep" sanitizer and store it encrypted (see `field-notes.md` §6). > **When options/meta are the wrong tool:** high-volume rows (logs, events, analytics, queues) > belong in a **custom table**, not in `wp_options` or post meta. `dbDelta()`, charset/collation, > indexing and the `varchar(191)` rule are in **`mariadb.md`**. --- ## 3. Metadata API — `register_meta` (REST- and Elementor-aware) ```php register_post_meta( 'myplugin_item', '_myplugin_subtitle', [ 'type' => 'string', 'single' => true, 'sanitize_callback' => 'sanitize_text_field', 'auth_callback' => function () { return current_user_can( 'edit_posts' ); }, 'show_in_rest' => true, // REST exposure + needed for Gutenberg / Elementor Dynamic Tags ] ); $subtitle = get_post_meta( $post_id, '_myplugin_subtitle', true ); update_post_meta( $post_id, '_myplugin_subtitle', sanitize_text_field( $subtitle ) ); ``` - **Prefix meta keys.** A leading underscore (`_myplugin_*`) marks the meta "protected" — hidden from the default Custom Fields UI and not editable by users directly. - **`show_in_rest` is required** for the value to drive an Elementor **Dynamic Tag** / Loop Grid or appear in the block editor. For arrays/objects pass a `show_in_rest` schema. - `register_term_meta()` / `register_user_meta()` follow the same shape. --- ## 4. Roles & Capabilities — gate by capability, never by role ```php // Authorisation check before any privileged action (nonces confirm ORIGIN, caps confirm PERMISSION): if ( ! current_user_can( 'edit_post', $post_id ) ) { // meta-cap with the object id where relevant wp_die( esc_html__( 'You are not allowed to do this.', 'myplugin' ) ); } // ❌ Never test a ROLE: current_user_can( 'administrator' ) is wrong — roles aren't capabilities. // ✅ Test a capability: 'manage_options', 'edit_posts', or a custom one. // Custom capability — add on activation, remove on uninstall (NOT on every page load). register_activation_hook( __FILE__, function () { foreach ( [ 'administrator', 'editor' ] as $role_name ) { get_role( $role_name )?->add_cap( 'myplugin_manage_items' ); } } ); // Then gate with: current_user_can( 'myplugin_manage_items' ) ``` --- ## 5. WP-Cron — scheduled tasks ```php // Schedule on activation, guarding against a duplicate event. register_activation_hook( __FILE__, function () { if ( ! wp_next_scheduled( 'myplugin_daily_task' ) ) { wp_schedule_event( time(), 'daily', 'myplugin_daily_task' ); } } ); add_action( 'myplugin_daily_task', 'myplugin_run_daily' ); // ✅ ALWAYS clear on deactivation — an orphaned event keeps firing forever. register_deactivation_hook( __FILE__, function () { wp_clear_scheduled_hook( 'myplugin_daily_task' ); } ); // Custom interval (built-ins: hourly, twicedaily, daily, weekly). add_filter( 'cron_schedules', function ( array $s ): array { $s['myplugin_5min'] = [ 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => esc_html__( 'Every 5 Minutes', 'myplugin' ) ]; return $s; } ); // One-off job with an argument: wp_schedule_single_event( time() + 10 * MINUTE_IN_SECONDS, 'myplugin_one_off', [ $item_id ] ); ``` > ⚠️ **WP-Cron is NOT a real system cron — it is triggered by site traffic.** On a low-traffic > site a "daily" task fires whenever the next visitor arrives, not at a fixed time. A *missed* > event is queued and runs on the next load (not abandoned). For **reliable timing**, disable the > traffic trigger and run a real server cron: > ```php > // wp-config.php > define( 'DISABLE_WP_CRON', true ); > ``` > ```cron > # crontab — every 5 minutes, headless: > */5 * * * * cd /path/to/wp && wp cron event run --due-now > /dev/null 2>&1 > ``` > For **heavy, high-volume, or must-complete** background work (imports, bulk emails, queues), > prefer **Action Scheduler** (the battle-tested queue bundled with WooCommerce) over raw WP-Cron — > it persists jobs to the DB, retries on failure, and processes in batches. > > ⚠️ **Action Scheduler 4.0.0 (bundled with WooCommerce 11.0, Aug 2026) changed `$unique`.** > Deduplication now compares the action's **arguments** as well as its hook and group. Two > `as_schedule_single_action()` calls that differ only in args used to block each other and now > **both schedule** — if you used `$unique` as a "only ever one of these queued" guard, that guard > is gone; dedupe on your own key instead. Cleanup of old actions also moved from inline > processing to a **daily 3 AM job**. Requires WP 6.8+. > Source: developer.woocommerce.com/2026/06/17/changes-to-action-scheduler/ --- ## 6. Internationalization (i18n) — required for the directory ```php // Header: "Text Domain: myplugin" — the text domain MUST EQUAL the plugin slug (Golden Rule #7). // wp.org-HOSTED plugin: add NOTHING. Do not call load_plugin_textdomain(). // Translations arrive as language packs and load themselves. See the trap below. // PRIVATE / self-hosted plugin only — this is the ONLY case where you need it: add_action( 'init', function () { load_plugin_textdomain( 'myplugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); } ); ``` > 🚨 **The bundled-`.mo` trap — you cannot ship translations inside a wp.org-hosted plugin.** > This looks like it should work and silently doesn't, so read the mechanism once: > > 1. WordPress resolves a plugin's translations through `WP_Textdomain_Registry` > (`wp-includes/class-wp-textdomain-registry.php`). Its `get_path_from_lang_dir()` scans > **`WP_LANG_DIR/plugins/`** — i.e. `wp-content/languages/plugins/` — and **nothing else.** > It never looks inside your plugin's own `/languages/` folder. > 2. So a `.mo` you ship in `myplugin/languages/` is **never loaded**. Your `.pot` is fine there > (it is a template, not a catalogue), but the compiled files are dead weight. > 3. The only thing that changes this is `load_plugin_textdomain()`, which registers a *custom > fallback path* on the registry — and **Plugin Check flags that call as discouraged**: > *"load_plugin_textdomain() has been discouraged since WordPress version 4.6. When your plugin > is hosted on WordPress.org, you no longer need to manually include this function call."* > 4. Net effect for a hosted plugin: bundling is either **dead weight** (no call) or a **review > warning** (with the call). Neither is what you want. > > **The supported route:** ship only the `.pot`; translations live on > [translate.wordpress.org](https://translate.wordpress.org/) and are delivered as **language > packs**, which install to `WP_LANG_DIR/plugins/` — exactly where the loader already looks. > Note the precedence is the right way round: a language pack always wins, and a custom path is > only a fallback, so an author-supplied catalogue can never shadow the community translation. ### Getting a locale actually translated (the parts that bite) - **90% or nothing.** wordpress.org generates the first language pack only once **≥90%** of that locale's strings are approved as **Current**. A "just the user-facing strings" catalogue typically lands near 35–40% and will **never** produce a pack. Translate the admin/settings/ control-label strings too, or the work ships to nobody. - **Anyone can import; only editors can approve.** Any wp.org user can use *Import Translations* on a plugin/theme project (the link appears at the bottom of a translation-set page **when logged in**). Imported strings land as **Waiting**. To set them Current you need **PTE** for that locale — request it on the front page of `make.wordpress.org/polyglots/` using the `#locale_code` tag (e.g. `#de_DE`) so the locale's GTEs are notified. - **Build the catalogue from the GlotPress export, not your local `.pot`.** Download the locale's PO from the project and fill *that* in. Two reasons: the `msgid`s then match the project exactly, and the export carries the locale's real **`Plural-Forms`**. These disagree — a locally generated POT gave `nplurals=2` for Georgian while GlotPress uses `nplurals=1`. - **Validate placeholders before importing.** A translation that loses or renames a `%s` / `%d` / `%1$s`, or drops an HTML tag, breaks output at runtime and passes every PHP lint. Diff the placeholder and tag sets between `msgid` and `msgstr` programmatically. - **`.l10n.php` is the fast path.** WP 6.5+ prefers `myplugin-{locale}.l10n.php` over `.mo`; the registry checks for both. Generate with `wp i18n make-php`. - **Interim use while you wait for a pack:** drop the compiled files into the site's own `wp-content/languages/plugins/`. That is the language-pack location, so the loader finds them with no plugin code and no Plugin Check warning, and a real pack later overwrites them. - **Machine translation is not acceptable unreviewed.** Polyglots states MT *"without human review…will NOT be considered acceptable"*. Have a native speaker read it before approving. > ⚠️ **WP 6.7+ — don't translate before `init`.** Calling `__()` / `_e()` / `esc_html__()` for your > text domain **earlier than the `init` action** now triggers a `_doing_it_wrong` notice: > *"Translation loading for the `myplugin` domain was triggered too early."* Don't translate at > file-load, `plugins_loaded`, or in a class constructor that runs early. `register_post_type` > labels, widget control labels, settings fields, etc. all run on/after `init` — those are fine. > Debug the offender: hook `doing_it_wrong_run` and `debug_print_backtrace()` when the function is > `_load_textdomain_just_in_time`. > Source: make.wordpress.org/core/2024/10/21/i18n-improvements-6-7/ ```php // Functions — always use the ESCAPING variants at output: esc_html__( 'Save', 'myplugin' ); esc_html_e( 'Save', 'myplugin' ); esc_attr__( 'Close', 'myplugin' ); esc_attr_e( 'Close', 'myplugin' ); // Plurals + placeholders (add a translator comment for context): printf( /* translators: %s: number of items */ esc_html( _n( '%s item', '%s items', $count, 'myplugin' ) ), esc_html( number_format_i18n( $count ) ) ); _x( 'Post', 'noun', 'myplugin' ); // disambiguate same word, different meaning // JavaScript strings (wp.i18n / block editor): wp_set_script_translations( 'myplugin-js', 'myplugin', plugin_dir_path( __FILE__ ) . 'languages' ); ``` - **Never** interpolate a variable into a translation string (`__( "Hi $name" )`) — use a placeholder (`sprintf( __( 'Hi %s', 'myplugin' ), $name )`) so the string is extractable. - Generate the template: **`wp i18n make-pot . languages/myplugin.pot`** (WP-CLI). - Plugin Check's **Internationalization** category flags a wrong/missing text domain and variable-in-string usage. -
wp-org-guidelines.md 12 KB
# WordPress.org Plugin Directory — Guidelines, Plugin Check & Review Reality > **When to read this file:** Any plugin destined for the **wordpress.org directory**. These are > the *policy* rules that get a plugin accepted or rejected — distinct from `field-notes.md` > (the practical "how it breaks" gotchas) and `php-standards.md` (the security *implementation*). > Read all three for a submission. Canonical, always-current source — verify against it before > relying on any summary: > developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/ --- ## The 18 Detailed Plugin Guidelines (paraphrased — actionable form) Reviewers cite these by number ("this fails Guideline 8"). Keep the numbering. 1. **GPL-compatible license.** All code, bundled libraries, data, and images must be **"GPLv2 or later" or another GPL-compatible license.** No "all rights reserved" assets. 2. **The developer is responsible for all files** in the plugin and for following the guidelines. Deliberately circumventing a rule is itself a violation. 3. **A stable version must be available from the directory** (your SVN `Stable tag`). Don't ship the real code only from your own site while the directory hosts a stub. 4. **Human-readable source — no obfuscation.** No packed/minified-only/encoded PHP whose source isn't included or clearly linked. JS/CSS may be minified **if** the unminified source is included or referenced. p,a,c,k,e,r and similar are rejected. 5. **No "trialware."** Functionality may not be crippled, time-locked, or disabled pending payment, and the plugin may not be a sandbox-only/demo. (Up-sells to a Pro version are fine; nagging or disabling core features is not.) 6. **SaaS is allowed** when the third-party service provides *substantial* functionality. Pure **license-key validation servers** and **storefront-only** plugins are not — the service must do real work, not just gate the plugin. 7. **No phoning home / tracking without explicit, opt-in consent.** Don't contact external servers or load remote assets on activation by default. Telemetry must be **opt-in**, clearly disclosed, with a privacy policy. (A genuine SaaS the user signed up for is the exception.) 8. **No executable code pulled from remote servers.** No self-updating from outside wordpress.org, no fetch-and-`eval()`, no loading PHP from a CDN. The directory is the only update source. 9. **No illegal or dishonest actions** — black-hat SEO, fake/incentivized reviews, sockpuppets, stolen code, botnets, crypto-mining, spam. 10. **No required credits / "Powered by" links.** Any front-end credit/attribution link must be **opt-in and default to OFF.** 11. **Don't hijack the admin.** Notices must be relevant, contextual, and **dismissible**; no persistent dashboard takeovers or unrelated upsell banners. Admin notices should auto-clear once the condition is resolved. 12. **No readme spam.** ≤ **5 tags**, no competitor/brand keyword stuffing, no affiliate-link farms, no black-hat SEO in the readme or other public-facing text. 13. **Use WordPress's bundled libraries** — `jquery`, `SimplePie`, `PHPMailer`, etc. — rather than bundling your own copy. (Ties to the skill's "no jQuery unless it's a WP-core dependency" rule and "declare `'swiper'` as a dependency, don't ship your own.") 14. **Don't abuse the SVN repo with frequent junk commits.** SVN is a *release* repo; commit deploy-ready code, not dozens of "wip/typo" revisions. 15. **Increment the version on every release** so users get the update notification. (See the lockstep version-bump checklist in `field-notes.md` §10.) 16. **The plugin must be complete and functional at submission.** Names/slugs can't be reserved for future work; an empty or stub plugin is rejected. 17. **Respect trademarks.** A slug may not **start** with a trademarked term you don't own. Use `"… for Brand"` / `"Tool for Brand"`, never `"Brand Tool"`. (Affects `get_name()` / text-domain / slug choices.) 18. **The directory reserves the right** to update guidelines, disable a plugin for user safety, grant case-by-case exceptions, and push emergency security fixes without author consent. --- ## Plugin Check (PCP) 2.1.0 — the reviewer's own tool The official plugin (by the WordPress.org team) that runs **most of the checks used for new submissions**. Requires WP 6.3+ / PHP 7.4+. **Run it until your own code is 0 findings before every submission and resubmission** (standing project rule). Passing is necessary but **not sufficient — human review is still mandatory.** > 🆕 **New in 2.1.0 (Aug 16, 2026)** — three of these will flag plugins that passed under 2.0.0: > - **SVN Checker** — scans the plugin's **wp.org SVN repository** for unexpected files. It sees > what you committed to `trunk/`, not just what is in your zip, so a stray dev file that > survived in SVN now surfaces even when your build is clean. (See the `svn/` sub-bundle.) > - **PHP Error Reporting check** — flags production-time modification of error settings, i.e. > calls like `ini_set( 'display_errors', … )` / `error_reporting( … )` in shipped code. > - **Per-dependency `Requires Plugins` validation** — each slug in the header is now checked > **individually** against the wp.org directory. A typo'd or non-directory slug (a Pro-only or > self-hosted dependency) is an error; only directory-hosted slugs belong in that header. > - Plus: distinct visual styles per issue type, Unicode preserved in exported results, and > improved WordPress-function compatibility data (feeding the `Requires at least` gate below). **Check categories:** - **Plugin Repository Requirements** — directory-guideline compliance (headers, readme, naming, no disallowed functions, trademark/branding). - **Security** — sanitization, escaping, nonces, SQL prep, file operations. - **Performance** — enqueue patterns, asset optimization. - **Accessibility** — accessible-markup checks. - **Code Standards** — PHPCS (WordPress Coding Standards) — emits the `WordPress.Security.*`, `WordPress.NamingConventions.*`, etc. codes. - **Internationalization** — correct use of i18n functions and a single, matching text domain. **How to run:** ```bash # WP-CLI (preferred for CI / scripted checks): wp plugin check <your-plugin-slug> wp plugin check <slug> --format=json --exclude-directories=includes/lib ``` - **Static checks run by default.** **Runtime checks** require loading the plugin's `cli.php` first: add `--require .../cli.php` before WordPress boots. - Admin UI: **Tools → Plugin Check**. - Since **October 2025, Plugin Check also runs automatically on every plugin update**, not just new submissions — a security regression in an update can get the plugin flagged/closed. See `field-notes.md` §3 for how PCP flags `echo $var` / `echo $this->method()`, the `phcs:ignore` philosophy, and the small set of *justified* false positives (core hook names, cache-opt-out constants, excluded vendored libs). --- ## The review process — what actually happens (2025–2026) - **Reviews are AI-assisted but human-decided.** Automated first-pass tooling handles the bulk (the team added 80+ internal checks and Plugin Check auto-scans), but a person makes the call — "these tools support reviewers; they don't replace them." - **Queue is typically days to ~a week** (submission volume surged to hundreds/week through 2026; the team scaled reviewers to keep turnaround short). Don't assume instant approval; build the submission cleanly the first time. - **Author responsiveness strongly correlates with approval.** Approved plugins average **several review cycles**, and a large share of rejected ones simply **never replied to the reviewer's email**. When a reviewer writes, **reply promptly and address every point** — silence is the most common path to rejection. - **Fix the source, don't argue or suppress.** Reviewers grep for hand-wavy `phcs:ignore` justifications and known evasion patterns (see `field-notes.md` §2–3). - **The review is multi-round and ESCALATES.** Round 1 is commonly an automated **AUTOPREREVIEW** (bot + AI-generated notes); later rounds are a **human reviewer** who catches what the bot missed and looks harder each pass. Fix the whole *class* of a flagged issue and self-audit for other occurrences — reviewers state "we may not share all cases." - **Reply in the SAME email thread** — concise, no change-logs, no AI filler. Reviewers re-review the **whole plugin**, they don't diff your changes; one complete, well-tested update per round beats many partial ones (each round can take days to weeks). --- ## Hard lines from real 2026 reviews — not spelled out in the guidelines - **No user-authored SQL over any endpoint — guards cannot fix it.** A tool running free-form caller-supplied `SELECT` was rejected even with SELECT-only checks, credential-column blocks, secret redaction and admin auth: free-form SQL **cannot be `prepare()`d** (the entire string is the input — no placeholders to bind), and exposing arbitrary DB reads over a remote, token-authenticated REST endpoint is an aggravating factor. The redesign that passed: a **structured query the plugin assembles itself** — **identifiers** (table, columns) validated against the **live schema** (`SHOW TABLES` / `DESCRIBE`) plus `^[a-zA-Z0-9_]+$`, then interpolated in backticks; **values** bound via `$wpdb->prepare()`. Rule of thumb for ANY user-input-to-DB feature: **whitelist identifiers, parameterize values.** If a feature only works by running user-authored SQL, it does not belong in a wp.org plugin (ship it GitHub-only, filter-gated, instead). - **Never use `AUTH_KEY` / auth salts outside core authentication** — not even to derive an opaque one-way HMAC identifier. Reviewers flag it; generate a dedicated plugin secret option instead. - **Document every external service in a `== External services ==` readme section** — what it is, what data is sent, when, plus ToS/privacy links. Required **even when the service connects INBOUND** and the plugin never phones out. - **Automated nonce flags on protocol endpoints are answered, not "fixed".** OAuth `/authorize` + `/token` (secured by PKCE + single-use codes) legitimately have no WP nonce — explain that in the reply; do **not** bolt on a meaningless nonce. (Admin-facing forms still verify `check_admin_referer()` + capability, always.) - **A standalone HTML page (echoed + `exit`, no `wp_head()`) still may not emit a raw `<style>` or `<link>`.** The pattern that satisfies both Plugin Check and the reviewer: `wp_register_style()` + `wp_enqueue_style()`, then print by handle with `wp_print_styles( 'my-handle' )` inside your own `<head>` — and move inline `style=""` attributes into that stylesheet too. --- ## Required headers & readme (cross-reference) A compliant submission needs, in lockstep (full checklist in `field-notes.md` §10): - **Main file header:** `Plugin Name`, `Version`, `Requires at least`, `Requires PHP`, `License: GPL-2.0-or-later`, `Text Domain`, and `Requires Plugins:` if it depends on Elementor. - **`readme.txt`:** `Stable tag` (must match the released version), `Requires at least` (the REAL feature floor — see the compatibility-gate note in `debugging.md` §1), `Tested up to` (**must equal the current WP major** or the readme check errors), `Requires PHP`, `License`, ≤ 5 tags, a `== Changelog ==`, and an `== Upgrade Notice ==` (< 300 chars each). - **Listing assets** (`screenshot-N.png` + captions, `banner-*`, `icon-*`) live in SVN `/assets/`, **not** in the plugin zip. > 🚀 **Actually deploying to the directory** (the `svn co` → copy to `trunk/` → `svn cp trunk > tags/X.Y.Z` → `svn ci` release dance, plus updating `/assets/`) is a Subversion task — see the > **`svn/`** sub-bundle (`svn/svn.md`), which has a WordPress.org-specific worked example. > Source of record (re-check before each submission, guidelines do change): > developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/ · > wordpress.org/plugins/plugin-check/ · make.wordpress.org/plugins/ (review-team announcements)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.