Custom Attributes

Every widget and div can carry a repeater of raw HTML attributes (data-*, aria-*, name, ...) on its own rendered tag(s), added on the Advanced tab. It's a generic mechanism — adding it to a new widget type is a one-line JSON change, never per-widget-type code.

Files:


What it looks like to the user

The Advanced tab's Attributes section is a repeater — each row has a Name (e.g. data-id), a Value, and (only when the widget has more than one addressable target) a Target dropdown. Name and Value are plain TextFields, so either one can be linked to a Site Variable or ACF field through the Variable Picker button that field already renders unconditionally — no extra code needed for that.

Not shown in this editor's live preview — Custom Attributes are frontend-only, applied by PHP at render time. A row's effect is only visible on the real published page.


attributeTargets — declaring a widget's addressable tags

A new top-level key in {type}.styles.json (see Style Field JSON Reference), an array of { target, label } entries:

"attributeTargets": [
    { "target": "", "label": "Wrapper" },
    { "target": ".sanilwb-icon", "label": "Icon" }
]
target Meaning
"" (empty string) The generic outer wrapper render_widgets() builds for every widget type unconditionally (.sanilwb-widget-row) — or, for a div, the div's own single tag. Applied via a plain string splice (build_custom_attributes_html()), no DOM parsing.
A real .classname One of the widget's own genuinely-rendered inner tags (e.g. .sanilwb-button, .sanilwb-text-input). Applied via DOM/XPath matching against the widget's own shortcode-rendered HTML (apply_custom_attributes()).

An empty attributeTargets array (or the key omitted) means the widget hasn't opted in at allbuildAttributesField() returns null and buildSharedAdvancedTab() leaves the whole Attributes section out of that widget's Advanced tab.

The Target dropdown itself only renders when attributeTargets.length > 1 — a widget with exactly one target (Button, Submit Button, a plain Container/Form div) has nothing to choose between, so showing a one-option dropdown would just be a decision with no other option to pick; the row is silently pinned to that one target instead (createAttributeRowFactory() pre-fills a new row's target with attributeTargets[0].target).

Current coverage

Targets Widgets
2 (Wrapper + own class) post_content (.sanilwb-post-content), excerpt (.sanilwb-excerpt), author (.sanilwb-author), date (.sanilwb-date), icon (.sanilwb-icon), spacer (.sanilwb-spacer)
1 (own tag only — the widget's whole Style tab already redirects there, no separate "Wrapper" concept) button (.sanilwb-button), submit_button (.sanilwb-submit-btn), and every Form field widget (text_input, textarea, select, radio_group, checkbox)
1 (Wrapper only) heading, paragraph, image, post_title, archive_title, featured_image, shortcodes, term_name, template_reference, carousel_nav, video, social_icons, menu
1 (Wrapper only — div's own single tag) Container, Form (structuralDialogTabs.js's divAttributeTargets, label "Container" or "Form")
Excluded entirely Off-Canvas — its trigger-button + panel two-tag structure isn't reachable via this single-target-per-div mechanism yet

Widgets left at Wrapper-only pending a real per-element design, not a guess: Carousel Nav (two buttons), Video (player/cover states), Social Icons (per-icon items), Menu (toggle/panel/submenu) — each would need its own multi-target design before a second entry is added, deferred rather than added speculatively.

"Typography own-tag" bare-tag widgets (Heading, Paragraph, Post Title, Excerpt, Archive Title, Author, Date, Term Name) render their own inner tag with no class of its own today, so they can only ever offer "Wrapper" until that inner tag is given a real, addressable class in both the JS and PHP render paths — an open question, not yet decided either way.


PHP: two application paths, one convention

SANILWB_Shortcode_Helpers::apply_custom_attributes( string $html, string $custom_attributes_json ): string — for a real (non-empty) target. Wired generically into SANILWB_Shortcode_Handler::process_shortcodes(), run on every widget's own shortcode-rendered HTML right after dispatch, regardless of type — no individual shortcode class needs to know this feature exists. Parses $html with DOMDocument+DOMXPath (wrapped in a throwaway <div data-sanilwb-attr-root="1"> marker, not id — an HTML fragment has no DTD for getElementById() to resolve against), matches nodes via a padded-space contains(concat(' ', normalize-space(@class), ' '), ' classname ') XPath query (so .sanilwb-button never wrongly matches sanilwb-button-wrapper), and re-extracts the marker's own children via saveHTML(). Both the attribute name and the target class name are stripped to a safe character allowlist as defense-in-depth against a tampered POST value reaching the XPath query unsanitized.

SANILWB_Shortcode_Helpers::build_custom_attributes_html( string $custom_attributes_json ): string — for target === '' (Wrapper/self) only; filters out any row with a real selector target, since those are apply_custom_attributes()'s job instead. No DOM parsing needed — the caller already knows exactly which opening tag it's building, so this just returns a ready-to-splice ' name="value"' string. Resolves its own copy of Dynamic Data tokens (SANILWB_Dynamic_Data_Registry::resolve_all_tokens()) rather than assuming the caller already did, since neither a div's own options nor a widget's pre-loop $values array pass through that resolution automatically at the point this runs. Wired directly into:

  • render_widgets()'s .sanilwb-widget-row echo — the generic wrapper every widget type gets, built entirely outside any shortcode's own render() call, which is why this is a separate mechanism from apply_custom_attributes() rather than the same one reused: a shortcode's render() return value never includes this outer wrapper, so apply_custom_attributes() can never reach it.
  • render_div()'s plain non-root <div> echo
  • render_form_open_tag()'s opening <form> tag (both the Custom Action and managed branches — see Forms)
  • render_root_wrapper_open()'s outer <div> echo

JS: the repeater config

buildAttributesField( attributeTargets ) (AttributeRow.jsx) builds the whole field config in one call — createAttributeRowFactory() (fresh-row defaults), createAttributeRowRenderer() (Name/Value/Target inputs), getAttributeRowTitle() (collapsed-row header text, falls back to "Attribute N" for an untouched row). Each widget's call site (config/widgets/index.js for a real widget type, structuralDialogTabs.js for a div) passes its own attributeTargets array in and hands the resulting field object to buildSharedAdvancedTab( attributesField ).

AttributeRow.jsx is deliberately not imported by sharedDialogFields.js itselfsharedDialogFields.js importing it would pull in TextField.jsx → the Zustand store → config/widgets/index.js, closing a circular-import loop back into the very file that imports sharedDialogFields.js. buildSharedAdvancedTab() instead accepts a pre-built field object (or null, to omit the section) — only index.js and structuralDialogTabs.js import AttributeRow.jsx directly.


Adding a new target to an existing widget

Add an entry to that widget's own {type}.styles.json attributeTargets array — nothing else. No PHP registration, no JS registration beyond what's already generic. The one thing to get right: the target string must be a real class that widget's own shortcode render() output actually carries on the tag you want addressable — apply_custom_attributes() matches against the rendered HTML, not against the styles.json schema itself, so a target naming a class that doesn't exist on any rendered tag silently matches nothing.


See also

  • Style Field JSON Reference — the full key-by-key reference for {type}.styles.json, including attributeTargets.
  • Forms — the field widgets whose own-tag Style pattern this feature's single-target widgets mirror.