N
Naveenr.dev
Chapter 101
12 min read2026-08-28

Dialog Show/Hide Widgets and Conditional Multifields

How Granite UI's dropdown-driven show/hide widget works inside a dialog and inside a multifield, why "hidden" fields aren't the same as "deleted" fields, and how dead hidden fields quietly pile up in real dialogs after a redesign.

A content author opens a component dialog, picks "Video" from a dropdown, and a Video URL field appears. Pick "Image" instead and a Path field appears in its place. Nobody wrote a line of JavaScript for that — it's a Granite UI convention baked into the dialog XML. The part that trips teams up isn't the show/hide behavior itself. It's what happens to the fields underneath it once the dialog gets redesigned a second or third time.

Problem

A component dialog needs to show different fields depending on what an author picks in another field — a classic case is a media tile that can be either a video or an image, where showing both a Video URL field and an Image Path field at once is confusing and half the fields will always be irrelevant. AEM's Touch UI (Coral) dialogs solve this with a pure-authoring-time mechanism: no server round trip, no custom widget code, just a granite:class + granite:data convention that toggles visibility client-side when the driving field's value changes.

The same problem shows up inside a multifield — an author adds several rows to a list, and each row itself needs the same show/hide behavior scoped to just that row, not the whole dialog.

The part that isn't obvious from the Granite UI docs: when this pattern gets added to a dialog that used to have a flat, non-conditional layout, the old fields don't disappear. They get marked hidden and left in the resource tree — which means a "hidden" field in a dialog is not the same thing as a deleted field, and it's easy for that distinction to matter later.

Architecture

Granite UI's show/hide mechanism has three moving parts, all declared as data attributes on plain widget resources — no custom Java, no custom JS:

  1. The driver — a select (dropdown) field carries granite:class="cq-dialog-dropdown-showhide-multifield" and a granite:data node with cq-dialog-dropdown-showhide-target pointing at a CSS selector for the elements it controls.
  2. The targets — each conditionally-shown container carries a matching granite:class (the same selector the driver points at) and its own granite:data node with showhidetargetvalue — the driver's value that makes this container visible.
  3. The wiring — a small client library (cq-dialog-dropdown-showhide.js, shipped with Granite UI, not something you write) reads those two data attributes and toggles container visibility whenever the driver's value changes, including on initial dialog load.

Inside a multifield, the same three pieces are declared once, inside the multifield's <field> node — Granite instantiates a fresh copy of that whole subtree (driver + targets) for every row the author adds, so each row's dropdown only ever controls its own row's containers, never a sibling row's.

code
select (mediaType)
  granite:class="cq-dialog-dropdown-showhide-multifield"
  granite:data: cq-dialog-dropdown-showhide-target=".media-type-target"
    option: value="video"
    option: value="image"

container (granite:class="media-type-target")
  granite:data: showhidetargetvalue="video"
  -> Video URL field, Video Title field

container (granite:class="media-type-target")
  granite:data: showhidetargetvalue="image"
  -> Image Path field, Alt Text field

Repository

A real dialog implementing this (from a media-tile component in a production AEM 6.5 codebase, field and component names generalized here):

xml
<!-- _cq_dialog/.content.xml -->
<mediaItems
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
    composite="{Boolean}true"
    fieldLabel="Media Items"
    required="{Boolean}true">
    <field
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/container"
        name="./mediaItemList">
        <items jcr:primaryType="nt:unstructured">
            <mediaType
                granite:class="cq-dialog-dropdown-showhide-multifield"
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/form/select"
                fieldLabel="Media Type"
                name="./mediaType">
                <granite:data
                    jcr:primaryType="nt:unstructured"
                    cq-dialog-dropdown-showhide-target=".media-type-target"/>
                <items jcr:primaryType="nt:unstructured">
                    <video jcr:primaryType="nt:unstructured" text="Video" value="video"/>
                    <image jcr:primaryType="nt:unstructured" text="Image" value="image"/>
                </items>
            </mediaType>
            <videoFields
                granite:class="media-type-target"
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/container">
                <items jcr:primaryType="nt:unstructured">
                    <videoUrl
                        jcr:primaryType="nt:unstructured"
                        sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                        fieldLabel="Video URL"
                        name="./videoUrl"/>
                </items>
                <granite:data jcr:primaryType="nt:unstructured" showhidetargetvalue="video"/>
            </videoFields>
            <imageFields
                granite:class="media-type-target"
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/container">
                <items jcr:primaryType="nt:unstructured">
                    <imagePath
                        jcr:primaryType="nt:unstructured"
                        sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
                        fieldLabel="Image Path"
                        name="./imagePath"
                        rootPath="/content/dam"/>
                </items>
                <granite:data jcr:primaryType="nt:unstructured" showhidetargetvalue="image"/>
            </imageFields>
        </items>
    </field>
</mediaItems>

How It Works

sling:hideResource="{Boolean}true" is the other half of this story, and it's a different mechanism from showhidetargetvalue. Granite UI's dialog rendering skips any resource marked sling:hideResource entirely — it isn't rendered, isn't in the DOM, and can't be submitted. It's used for two genuinely different reasons that are easy to conflate:

  • Deliberate exclusion via dialog merge — when a component's dialog is merged with a super-type's dialog (via sling:resourceSuperType and the Sling Resource Merger), sling:hideResource on a node with the same name as an inherited node suppresses that inherited field from rendering. This is the "hide an inherited field" mechanism (covered in the companion chapter on extending Core Components).
  • Leftover fields from a redesign — a field that used to be part of the dialog's normal layout, now hidden because a newer pattern (like the multifield above) replaced it, but never actually removed from the content structure.

The second case is the one worth paying attention to, because it's invisible unless someone opens the raw .content.xml. In the dialog above, imagine an earlier version of this component had flat, non-multifield ./imagePath and ./altText fields directly on the dialog root — added before anyone needed multiple media items per component. When the multifield was introduced, the simplest edit was to mark the old fields sling:hideResource="{Boolean}true" rather than delete them (faster, and "just in case" something still reads them). Nothing in the dialog's own XML tells you those fields are dead — the resource tree doesn't distinguish "correctly excluded via merge" from "abandoned leftover."

Real Project Example

A retail client's product-tile component had exactly this history: version 1 supported a single image, authored via flat ./imagePath/./altText fields. Version 2 added the video-or-image choice and the multifield structure shown above, and the old flat fields were hidden rather than deleted — the commit message said "keep for now in case migration script needs them." Eighteen months later, a new developer doing an unrelated dialog cleanup found the hidden imagePath/altText nodes, assumed they were an inherited field being intentionally suppressed (the merge-exclusion use case), and left them alone rather than investigating further — a reasonable but wrong guess, since there was no sling:resourceSuperType on this component at all. The Sling Model backing the component had already been fully migrated to read only from the multifield's child resources; the flat fields had zero code path reading them. They were pure dead weight in the content structure, discoverable only by grepping the Java model for the exact property name and finding nothing.

Production Troubleshooting

  • A hidden field with content still in the JCR isn't harmless. If package installs, content copies, or a bulk content migration ever write to the old property name (because a script or spreadsheet still references it), that value sits in the repository forever with nothing reading it — and nothing warns you it's stale.
  • Don't assume sling:hideResource always means "inherited and intentionally suppressed." Check whether the component even has a sling:resourceSuperType before assuming that's the reason. If it doesn't, the hidden field is almost certainly a leftover, not a merge exclusion.
  • showhidetargetvalue matching is a plain string comparison against the driver's value. If a dropdown's option values are ever renamed (say, "wistiaVideo" becomes "video" for consistency), every showhidetargetvalue referencing the old string silently stops matching — the container just never shows again, with no error anywhere.
  • Multifield-scoped show/hide only works if the target's granite:class selector is scoped to that row. A class name that happens to collide with something elsewhere in the dialog (outside the multifield) can cause a driver in one row to toggle a container in a completely different part of the dialog.

Why Architects Care

Show/hide widgets and multifields are both zero-code, purely declarative — which is exactly why they accumulate technical debt invisibly. There's no compiler, no lint rule, and usually no code review scrutiny applied to a .content.xml dialog file the way there is to a Java class. A field that's dead weight in a dialog doesn't throw an exception or fail a test; it just sits there, authorable, silently ignored, until someone burns an hour investigating why filling it in "does nothing."

Summary

  • The dropdown show/hide widget is three declarative pieces: a driver (cq-dialog-dropdown-showhide-multifield + a target selector), one or more targets (showhidetargetvalue), and a stock Granite client library that wires them together — no custom code needed.
  • The same mechanism works unchanged inside a multifield; Granite instantiates a fresh driver+targets subtree per row.
  • sling:hideResource is a different, broader mechanism (full exclusion from rendering) used for two different reasons: suppressing an inherited field via dialog merge, or masking a leftover field after a redesign — and the dialog XML itself doesn't tell you which.
  • Treat a hidden field with no corresponding sling:resourceSuperType as a strong signal it's dead, and confirm by grepping the Sling Model/HTL for the property name before assuming it's still load-bearing.

What's Next

The companion piece on extending Adobe's Core Components covers the other half of sling:hideResource — using it deliberately, through dialog merge, to suppress a field your component inherits from a Core Component's own dialog.

Want to See This Applied to a Real Problem?

See Dead Hidden Fields After a Multifield Migration for a full walkthrough of tracing exactly this situation down to a dead property, plus a template-contract test that catches it before it ships again.

Enjoyed this chapter?

Get an email when I publish the next chapter. No spam — just new technical deep-dives.

Comments

Share feedback or questions about this blog post.

No comments yet. Be the first to share your thoughts.